chore(webui): enforce semantic interface patterns
Dependency Audit / dependency-audit (push) Successful in 1m46s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m24s

This commit is contained in:
2026-08-19 18:47:45 +02:00
parent ef8fd45457
commit e88dceb639
4 changed files with 102 additions and 103 deletions
@@ -89,6 +89,17 @@ exceptional canvas needs only the shared heading. Specialized layouts such as
`AdminPageLayout` compose these lower-level Core contracts; they do not repeat `AdminPageLayout` compose these lower-level Core contracts; they do not repeat
markup or responsive CSS. markup or responsive CSS.
`PageActionBar` is the semantic action contract for headed pages;
`WorkspaceActionBar` applies the identical ordering and lifecycle rules to a
full canvas and its collection, detail, and editor panes. Reload is always the
leading action on a refreshable projection. Help and ordinary task actions
follow contextual controls; Create is the far-right collection action;
destructive actions occupy a named separated group; an editor ends with
Discard and Save, with Save at the far right. Editor state is explicit:
`clean`, `dirty`, `invalid`, `saving`, `save-failed`, or `conflict`. Lower-level
`ActionToolbar` remains appropriate for a section-local view switch or compact
control group, but it must not recreate page or pane action placement.
Composite workspaces whose selected contribution supplies its own semantic Composite workspaces whose selected contribution supplies its own semantic
heading may use `PageLayout` with its visible header delegated. This preserves heading may use `PageLayout` with its visible header delegated. This preserves
the central inset, loading boundary, help identity, and content frame without the central inset, loading boundary, help identity, and content frame without
@@ -120,6 +131,10 @@ The current structural vocabulary is:
distribution, density, grouping, panel/section surfaces, accessible toolbar distribution, density, grouping, panel/section surfaces, accessible toolbar
naming, help identity, and responsive wrapping. Modules may add naming, help identity, and responsive wrapping. Modules may add
domain-specific presentation; they do not recreate the flex/wrap skeleton. domain-specific presentation; they do not recreate the flex/wrap skeleton.
- `PageActionBar` and `WorkspaceActionBar` own semantic ordering, Reload,
editor persistence state, destructive separation, and page/pane scope. A
module supplies action behavior, authority, blocker reasons, and wording;
it does not assemble another panel-header action convention.
- `WorkspaceFrame` and `WorkspaceLayout` own application-viewport framing, - `WorkspaceFrame` and `WorkspaceLayout` own application-viewport framing,
surfaces, overflow, list/detail and navigation/content pane geometry, surfaces, overflow, list/detail and navigation/content pane geometry,
accessible region identity, and responsive pane behavior. Modules own only accessible region identity, and responsive pane behavior. Modules own only
@@ -144,8 +159,13 @@ The current structural vocabulary is:
validation, permissions, or domain wording. validation, permissions, or domain wording.
- `MetricGrid` owns the responsive grouping around `MetricCard`: fixed one-to-five - `MetricGrid` owns the responsive grouping around `MetricCard`: fixed one-to-five
columns or auto-fit, minimum card width, density, surrounding rhythm, and a columns or auto-fit, minimum card width, density, surrounding rhythm, and a
named collapse point. Modules provide the metric, tone, and consequence; named collapse point. `MetricCard.drilldown` provides an explicit link or
they do not recreate the group grid or reach across module CSS to size it. in-page action when an authorized underlying detail helps the user act; it
names that destination and preserves the current scope and filters. The card
itself is never the hidden click target. Derived, privacy-suppressed,
non-enumerable, and purely informational aggregates remain inert. Modules
provide the metric, tone, destination, and consequence; they do not recreate
the group grid or reach across module CSS to size it.
- `DescriptionList` and `DescriptionItem` own semantic property presentation. - `DescriptionList` and `DescriptionItem` own semantic property presentation.
The stacked variant supports compact multi-column facts; the inline variant The stacked variant supports compact multi-column facts; the inline variant
supports one-column term/value rows with a standard term width. Both own supports one-column term/value rows with a standard term width. Both own
@@ -230,7 +250,8 @@ one.
names the object. Do not duplicate that action in a permanently visible side names the object. Do not duplicate that action in a permanently visible side
panel. A side panel used as the creation surface appears for creation and is panel. A side panel used as the creation surface appears for creation and is
otherwise absent or returns to its documented non-creation purpose. otherwise absent or returns to its documented non-creation purpose.
- Put filters beside the list or pane they affect. Put bulk actions immediately - Put filters beside the list or pane they affect. Put collection, detail, and
editor-pane actions in `WorkspaceActionBar` with the matching scope. Put bulk actions immediately
above or beside the current selection. Put object actions with the object above or beside the current selection. Put object actions with the object
detail, not in the global title bar. detail, not in the global title bar.
- Full-page create and edit surfaces put their persistent action cluster in the - Full-page create and edit surfaces put their persistent action cluster in the
@@ -78,10 +78,11 @@ matters.
A Quick Access contribution declares: A Quick Access contribution declares:
- a stable id, category and human label; - contract version 1, a stable id, category and human label;
- icon, order and optional badge/summary provider; - icon, order and optional badge/summary provider;
- required permissions and optional dependencies; - required permissions and optional dependencies;
- accepted context references and produced return references; - global or active-object availability, accepted context-reference kinds and
produced result-reference kinds;
- an owner-rendered bounded WebUI surface and full-page fallback route; - an owner-rendered bounded WebUI surface and full-page fallback route;
- View surface, help context and availability explanation; - View surface, help context and availability explanation;
- whether the contribution supports preview, create, select or resume. - whether the contribution supports preview, create, select or resume.
@@ -92,6 +93,22 @@ resources and a safe return location. The owner reauthorizes every read and
effect. Credentials, protected content and permission decisions are never effect. Credentials, protected content and permission decisions are never
embedded in launch context. embedded in launch context.
Launch-context version 2 identifies reference contract version 1 and carries
the exact resolved View revision plus optional recommended and focused tool
ids. Recommendations affect order and emphasis only. Focus narrows the rail
only when at least one focused contribution survives module enablement,
configuration, context compatibility and authorization; otherwise the normal
effective rail remains available. Workflow gets the same behavior by resolving
the exact View revision instead of acquiring separate presentation authority.
An owner-rendered tool explicitly returns result contract version 1 as either
`completed` with an action and typed owner reference, or `cancelled` with a
reason. The shell correlates the result with the source and tool, rejects
cross-tenant or undeclared reference kinds, and does not interpret closing the
drawer as completion. Owner modules validate, persist, recover and audit their
own effects. The overlay leaves the host route mounted, so unsaved host-page
state is preserved; the full-page route remains the bounded-work fallback.
## Effective Configuration ## Effective Configuration
The effective rail is resolved from: The effective rail is resolved from:
@@ -125,9 +142,9 @@ workspace layouts do not resize unexpectedly; a later explicit pinned mode may
reserve layout width on sufficiently wide screens. reserve layout width on sufficiently wide screens.
The drawer preserves host-page state, has a deterministic focus return, closes The drawer preserves host-page state, has a deterministic focus return, closes
with Escape, supports keyboard traversal, and provides an explicit full-page with Escape, supports keyboard traversal, and provides explicit completion,
open action. Mobile and narrow layouts use the same category/configuration cancellation and full-page actions. Mobile and narrow layouts use the same
semantics in a bottom sheet or compact menu. category/configuration semantics in a bottom sheet or compact menu.
## Product Areas ## Product Areas
+56 -94
View File
@@ -28,59 +28,22 @@ CENTRAL_LAYOUT = pathlib.Path("govoplan-core/webui/src/components/PageLayout.tsx
CENTRAL_WORKSPACE_LAYOUT = pathlib.Path( CENTRAL_WORKSPACE_LAYOUT = pathlib.Path(
"govoplan-core/webui/src/components/WorkspaceLayout.tsx" "govoplan-core/webui/src/components/WorkspaceLayout.tsx"
) )
REQUIRED_CONSUMERS = ( CENTRAL_ACTION_BARS = {
pathlib.Path("govoplan-core/webui/src/components/admin/AdminPageLayout.tsx"), pathlib.Path("govoplan-core/webui/src/components/PageActionBar.tsx"),
pathlib.Path("govoplan-core/webui/src/features/dashboard/DashboardPage.tsx"), pathlib.Path("govoplan-core/webui/src/components/WorkspaceActionBar.tsx"),
pathlib.Path("govoplan-dashboard/webui/src/features/dashboard/DashboardPage.tsx"),
pathlib.Path("govoplan-ops/webui/src/features/ops/OpsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignAuditPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignFieldsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignJsonView.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignListPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignOverviewPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignReportPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/GlobalSettingsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/MailSettingsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/ReviewSendPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/TemplateDataPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/components/CampaignDraftPageScaffold.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/wizard/WizardDirectoryPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/operator/OperatorQueuePage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/reports/AggregateReportsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/templates/TemplatesPage.tsx"),
pathlib.Path("govoplan-access/webui/src/features/admin/AdminPage.tsx"),
pathlib.Path("govoplan-core/webui/src/features/settings/SettingsPage.tsx"),
pathlib.Path("govoplan-docs/webui/src/features/docs/DocsPage.tsx"),
pathlib.Path("govoplan-mail/webui/src/features/mail/MailBouncePage.tsx"),
pathlib.Path("govoplan-payments/webui/src/features/payments/PaymentsPage.tsx"),
)
REQUIRED_WORKSPACE_CONSUMERS = (
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignWorkspace.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignModulePage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/templates/TemplatesPage.tsx"),
pathlib.Path("govoplan-approvals/webui/src/features/approvals/ApprovalsPage.tsx"),
pathlib.Path("govoplan-access/webui/src/features/admin/AdminPage.tsx"),
pathlib.Path("govoplan-core/webui/src/features/settings/SettingsPage.tsx"),
pathlib.Path("govoplan-docs/webui/src/features/docs/DocsPage.tsx"),
pathlib.Path("govoplan-organizations/webui/src/features/organizations/OrganizationsPage.tsx"),
)
HEADERLESS_PAGE_LAYOUT_CONSUMERS = {
pathlib.Path("govoplan-access/webui/src/features/admin/AdminPage.tsx"),
} }
EDITOR_PAGE_CONSUMERS = ( SEMANTIC_ACTION_USAGE = re.compile(r"<(?:Page|Workspace)ActionBar\b")
pathlib.Path("govoplan-core/webui/src/features/settings/SettingsPage.tsx"), EDITOR_ACTION_USAGE = re.compile(
pathlib.Path("govoplan-dashboard/webui/src/features/dashboard/DashboardPage.tsx"), r"<(?:Page|Workspace)ActionBar\b[\s\S]{0,1200}?variant=\"editor\""
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignFieldsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignOverviewPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/GlobalSettingsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/MailSettingsPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/TemplateDataPage.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/components/CampaignDraftPageScaffold.tsx"),
) )
DESTRUCTIVE_PAGE_CONSUMERS = ( PANEL_HEADER_ACTION_TOOLBAR = re.compile(
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignOverviewPage.tsx"), r"<ActionToolbar\b[^>]*\bsurface=\"panel-header\""
)
UNSAVED_GUARD_MARKERS = (
"useUnsavedDraftGuard",
"useCampaignDraftEditor",
"useRegisterUnsavedChanges",
"semantic-editor-guard:",
) )
@@ -108,6 +71,22 @@ def relative(path: pathlib.Path) -> pathlib.Path:
def main() -> int: def main() -> int:
sources = source_paths() sources = source_paths()
source_text = {relative(path): path.read_text(encoding="utf-8") for path in sources} source_text = {relative(path): path.read_text(encoding="utf-8") for path in sources}
page_consumers = {
path for path, text in source_text.items()
if path != CENTRAL_LAYOUT and PAGE_LAYOUT_USAGE.search(text)
}
workspace_consumers = {
path for path, text in source_text.items()
if path != CENTRAL_WORKSPACE_LAYOUT and "<WorkspaceLayout" in text
}
action_consumers = {
path for path, text in source_text.items()
if path not in CENTRAL_ACTION_BARS and SEMANTIC_ACTION_USAGE.search(text)
}
editor_consumers = {
path for path, text in source_text.items()
if path not in CENTRAL_ACTION_BARS and EDITOR_ACTION_USAGE.search(text)
}
raw_frames = {path for path, text in source_text.items() if RAW_PAGE_FRAME in text} raw_frames = {path for path, text in source_text.items() if RAW_PAGE_FRAME in text}
baseline = baseline_paths(BASELINE_PATH) baseline = baseline_paths(BASELINE_PATH)
available_baseline = { available_baseline = {
@@ -160,9 +139,9 @@ def main() -> int:
if ( if (
page_layout_count page_layout_count
and "actions=" in text and "actions=" in text
and path not in HEADERLESS_PAGE_LAYOUT_CONSUMERS
and path != pathlib.Path("govoplan-core/webui/src/components/admin/AdminPageLayout.tsx") and path != pathlib.Path("govoplan-core/webui/src/components/admin/AdminPageLayout.tsx")
and "<PageActionBar" not in text and "<PageActionBar" not in text
and "semantic-page-actions: delegated" not in text
): ):
errors.append( errors.append(
f"Headed page actions must use the semantic PageActionBar: {path}" f"Headed page actions must use the semantic PageActionBar: {path}"
@@ -172,51 +151,31 @@ def main() -> int:
f"Ambiguous consequential action slots are forbidden; use destructiveActions: {path}" f"Ambiguous consequential action slots are forbidden; use destructiveActions: {path}"
) )
for path in REQUIRED_CONSUMERS: if (
absolute_path = REPOS_ROOT / path path not in CENTRAL_ACTION_BARS
if not absolute_path.exists(): and PANEL_HEADER_ACTION_TOOLBAR.search(text)
continue ):
text = absolute_path.read_text(encoding="utf-8") errors.append(
if "<PageLayout" not in text: "Panel-header actions must use WorkspaceActionBar so their ordering, "
errors.append(f"Required shared-layout consumer no longer uses PageLayout: {path}") f"state, and destructive separation remain semantic: {path}"
if RAW_PAGE_FRAME in text: )
errors.append(f"Required shared-layout consumer restored the raw page frame: {path}")
for path in REQUIRED_WORKSPACE_CONSUMERS: if "<WorkspaceFrame" in text and not SEMANTIC_ACTION_USAGE.search(text):
absolute_path = REPOS_ROOT / path errors.append(
if not absolute_path.exists(): f"WorkspaceFrame routes must declare a semantic page or pane action bar: {path}"
continue )
text = absolute_path.read_text(encoding="utf-8")
if "<WorkspaceLayout" not in text:
errors.append(f"Required shared-workspace consumer no longer uses WorkspaceLayout: {path}")
if RAW_WORKSPACE.search(text):
errors.append(f"Required shared-workspace consumer restored the raw workspace: {path}")
for path in EDITOR_PAGE_CONSUMERS: for path in editor_consumers:
absolute_path = REPOS_ROOT / path text = source_text[path]
if not absolute_path.exists(): for required in ('variant="editor"', "state=", "discardAction=", "saveAction="):
continue
text = absolute_path.read_text(encoding="utf-8")
for required in ('variant="editor"', "dirty=", "discardAction=", "saveAction="):
if required not in text: if required not in text:
errors.append(f"Editor page is missing {required}: {path}") errors.append(f"Editor page is missing {required}: {path}")
if path.name != "CampaignDraftPageScaffold.tsx" and not any( if not any(guard in text for guard in UNSAVED_GUARD_MARKERS):
guard in text
for guard in (
"useUnsavedDraftGuard",
"useCampaignDraftEditor",
"useRegisterUnsavedChanges",
)
):
errors.append(f"Editor page is missing an unsaved-change guard: {path}") errors.append(f"Editor page is missing an unsaved-change guard: {path}")
for path in DESTRUCTIVE_PAGE_CONSUMERS: for path, text in source_text.items():
absolute_path = REPOS_ROOT / path if "destructiveActions=" in text and 'variant="danger"' not in text:
if not absolute_path.exists(): errors.append(f"Destructive page actions must contain a danger action: {path}")
continue
text = absolute_path.read_text(encoding="utf-8")
if "destructiveActions=" not in text or 'variant="danger"' not in text:
errors.append(f"Destructive page actions lost their separated slot: {path}")
core_index = REPOS_ROOT / "govoplan-core/webui/src/index.ts" core_index = REPOS_ROOT / "govoplan-core/webui/src/index.ts"
if core_index.exists() and "PageLayout, PageHeader" not in core_index.read_text(encoding="utf-8"): if core_index.exists() and "PageLayout, PageHeader" not in core_index.read_text(encoding="utf-8"):
@@ -225,6 +184,8 @@ def main() -> int:
errors.append("Core must export WorkspaceLayout from @govoplan/core-webui.") errors.append("Core must export WorkspaceLayout from @govoplan/core-webui.")
if core_index.exists() and "PageActionBar" not in core_index.read_text(encoding="utf-8"): if core_index.exists() and "PageActionBar" not in core_index.read_text(encoding="utf-8"):
errors.append("Core must export PageActionBar from @govoplan/core-webui.") errors.append("Core must export PageActionBar from @govoplan/core-webui.")
if core_index.exists() and "WorkspaceActionBar" not in core_index.read_text(encoding="utf-8"):
errors.append("Core must export WorkspaceActionBar from @govoplan/core-webui.")
if errors: if errors:
print("\n".join(errors), file=sys.stderr) print("\n".join(errors), file=sys.stderr)
@@ -232,11 +193,12 @@ def main() -> int:
print( print(
"Shared WebUI layout contract passed: " "Shared WebUI layout contract passed: "
f"{len(REQUIRED_CONSUMERS)} central consumers, " f"{len(page_consumers)} discovered page consumers, "
f"{sum(len(PAGE_LAYOUT_USAGE.findall(text)) for text in source_text.values())} semantic pages, " f"{sum(len(PAGE_LAYOUT_USAGE.findall(text)) for text in source_text.values())} semantic pages, "
f"{len(EDITOR_PAGE_CONSUMERS)} guarded editor consumers, " f"{len(action_consumers)} semantic action consumers, "
f"{len(editor_consumers)} guarded editor consumers, "
f"{len(raw_frames)} registered legacy page-frame files; " f"{len(raw_frames)} registered legacy page-frame files; "
f"{len(REQUIRED_WORKSPACE_CONSUMERS)} workspace consumers, " f"{len(workspace_consumers)} discovered workspace consumers, "
f"{len(raw_workspaces)} registered legacy workspace files." f"{len(raw_workspaces)} registered legacy workspace files."
) )
return 0 return 0
@@ -125,7 +125,6 @@ REQUIRED_CONSUMERS = {
pathlib.Path("govoplan-core/webui/src/components/WysiwygEditor.tsx"), pathlib.Path("govoplan-core/webui/src/components/WysiwygEditor.tsx"),
pathlib.Path("govoplan-calendar/webui/src/features/calendar/CalendarPage.tsx"), pathlib.Path("govoplan-calendar/webui/src/features/calendar/CalendarPage.tsx"),
pathlib.Path("govoplan-files/webui/src/features/files/FilesPage.tsx"), pathlib.Path("govoplan-files/webui/src/features/files/FilesPage.tsx"),
pathlib.Path("govoplan-forms/webui/src/features/forms/FormsPage.tsx"),
pathlib.Path("govoplan-templates/webui/src/features/templates/TemplatesPage.tsx"), pathlib.Path("govoplan-templates/webui/src/features/templates/TemplatesPage.tsx"),
), ),
"ContentGrid": ( "ContentGrid": (