Enforce shared metric and description layouts
Dependency Audit / dependency-audit (push) Successful in 1m39s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 11m3s

This commit is contained in:
2026-08-18 11:30:39 +02:00
parent 5efb0eea6f
commit 5bb8028147
4 changed files with 230 additions and 25 deletions
@@ -115,18 +115,29 @@ The current structural vocabulary is:
- `FormSection` owns form-section heading, description, actions, content flow, - `FormSection` owns form-section heading, description, actions, content flow,
separation, and panel presentation. It does not own field values, separation, and panel presentation. It does not own field values,
validation, permissions, or domain wording. validation, permissions, or domain wording.
- `MetricGrid` owns the responsive grouping around `MetricCard`: fixed one-to-five
columns or auto-fit, minimum card width, density, surrounding rhythm, and a
named collapse point. Modules provide the metric, tone, and consequence;
they do not recreate the group grid or reach across module CSS to size it.
- `DescriptionList` and `DescriptionItem` own semantic property presentation.
The stacked variant supports compact multi-column facts; the inline variant
supports one-column term/value rows with a standard term width. Both own
density, wrapping, and responsive collapse while modules retain the terms,
values, provenance, and actions.
- `Dialog` owns size and administration variants, body padding, description, - `Dialog` owns size and administration variants, body padding, description,
notices, and fixed footer placement. `DialogActions`, `DialogForm`, and notices, and fixed footer placement. `DialogActions`, `DialogForm`, and
`DialogSection` own the footer action flow, native form flow, and body `DialogSection` own the footer action flow, native form flow, and body
grouping used inside it. Modules compose fields and consequences rather than grouping used inside it. Modules compose fields and consequences rather than
recreating dialog anatomy. recreating dialog anatomy.
Raw toolbar tags, the former generic grid classes, raw dialog-form wrappers, Raw toolbar tags, the former generic grid and property-list classes, raw
and module-local definitions of these contracts are rejected by the focused dialog-form wrappers, and module-local definitions of these contracts are
workspace checks. The next promotion candidates are metric groups, rejected by the focused workspace checks. Dialog widths matching the Core size
description/property lists, filter/search composition, responsive data scale must use `Dialog size`; other local widths require a reviewed exception
summaries, and unified empty/error state composition. Their APIs must remain and may only decrease. The next promotion candidates are assignment/picker
composable; a central component is not a single oversized page template. composition, filter/search composition, and unified empty/error state
composition. Their APIs must remain composable; a central component is not a
single oversized page template.
## Surface Archetypes ## Surface Archetypes
@@ -1,7 +1,8 @@
# Shared WebUI Primitive Inventory # Shared WebUI Primitive Inventory
This 2026-08-18 inventory records the implementation state after the first This 2026-08-18 inventory records the implementation state after the
product-wide toolbar, grid, form-section, and dialog-anatomy migration. It is product-wide toolbar, grid, form-section, dialog-anatomy, metric-group, and
description-list migrations. It is
evidence for prioritization, not a substitute for the normative evidence for prioritization, not a substitute for the normative
[interface pattern language](../architecture/INTERFACE_PATTERN_LANGUAGE.md). [interface pattern language](../architecture/INTERFACE_PATTERN_LANGUAGE.md).
@@ -16,25 +17,38 @@ evidence for prioritization, not a substitute for the normative
| `DialogActions` | Every Core `Dialog` footer | Footer wrapping and alignment are no longer repeated by consumers. | | `DialogActions` | Every Core `Dialog` footer | Footer wrapping and alignment are no longer repeated by consumers. |
| `DialogForm` | 6 source files | Raw `*-dialog-form` form wrappers are rejected. | | `DialogForm` | 6 source files | Raw `*-dialog-form` form wrappers are rejected. |
| `DialogSection` | 6 source files | Repeated dialog field/content grouping is available as a shared primitive. | | `DialogSection` | 6 source files | Repeated dialog field/content grouping is available as a shared primitive. |
| `MetricGrid` | 29 uses in 25 source files | The former `metric-grid` class and module-specific column overrides were removed. Core now owns 15/auto-fit columns, minimum width, density, block/inset/zero spacing, and collapse. |
| `DescriptionList` and `DescriptionItem` | 51 lists in 28 source files; item composition in 11 files | The former `admin-details-grid` and `detail-list` classes were removed. Core now owns stacked and inline property layouts, density, term width, wrapping, and collapse while preserving native `dl`/`dt`/`dd` semantics. |
| Standard dialog sizing | 9 consumers and 6 duplicated width rules removed | Confirmations and compatible Calendar, credential, Campaign, Files, and Voting dialogs use the Core small/large/wide scale. The 61 remaining specialized selectors across 22 CSS files are registered and decrease-only. |
`tools/checks/check-shared-webui-primitives.py` verifies Core exports and `tools/checks/check-shared-webui-primitives.py` verifies Core exports and
ownership, representative consumers, the absence of the retired raw anatomy, ownership, representative consumers, the absence of the retired raw anatomy,
and composition of every `Dialog` footer through `DialogActions`. and composition of every `Dialog` footer through `DialogActions`.
## Dialog Width Classification
The remaining 61 width selectors do not duplicate the Core 460/560/680/1040/
1440px scale. They cover bounded editor widths between scale steps, high-density
definition and governance editors, preview/chooser canvases, message and file
overlays with coupled height behavior, and responsive full-canvas workflows.
Their exact selector set lives in
`tools/checks/shared-webui-dialog-width-exceptions.txt`. The focused check fails
for a new selector, a stale baseline entry, or any local width that duplicates
the Core scale.
## Remaining Promotion Candidates ## Remaining Promotion Candidates
The post-migration scan found 101 uses of 30 non-Core grid class names across The post-migration scan still finds domain-specific grids, but the repeated
54 files. Most are intentionally domain-specific; repeated groups identify the generic metric and property-list geometry is gone. Remaining grids are mostly
next useful primitives: unequal-track editors, visualizations, workflow facts, import mappings, and
collection layouts. The next useful candidates depend on interaction semantics:
| Priority | Remaining pattern | Evidence | Proposed contract | | Priority | Remaining pattern | Evidence | Proposed contract |
| --- | --- | ---: | --- | | --- | --- | ---: | --- |
| 1 | Metric-card groups | `metric-grid`: 29 uses in 25 files | `MetricGrid` or `MetricGroup` owning 14/auto-fit columns, compact spacing, inset mode, and narrow stacking around the existing `MetricCard`. | | 1 | Empty and collection state anatomy | More than 70 module-prefixed empty/state class uses remain; only loading, alerts, blockers, and DataGrid empty actions are centralized | A composable `StatePanel`/`CollectionState` covering empty, recoverable error, permission block, partial result, and next action without hiding domain consequences. First distinguish a whole-surface state from a compact empty row or optional-value placeholder. |
| 2 | Property/description lists | `admin-details-grid`: 13 uses in 11 files | Semantic `DescriptionList` with compact/default density, 12 columns, wrapping, and narrow collapse. | | 2 | Filter and search rows | More than 50 filter/search class tokens cover simple text search, facets, popovers, result counts, active filters, bulk selection, and overlay search | Define `FilterBar` only after the input/submit, live filtering, facet, result-summary, and bulk-action accessibility variants have been compared. Plain action placement already uses `ActionToolbar`. |
| 3 | Assignment/picker groups | `admin-assignment-grid`: 5 uses in 4 files | A selection/assignment layout only after its list, search, empty, and permission variants are compared; plain geometry can already use `ContentGrid`. | | 3 | Assignment/picker groups | `admin-assignment-grid`: 5 uses in 4 files | Promote a selection/assignment composition only after its list, search, empty, policy, and permission variants are compared; plain geometry can already use `ContentGrid`. |
| 4 | Filter and search rows | Toolbars are centralized, but filter fields, result counts, bulk selection, and action grouping still vary | `FilterBar` composed from `ActionToolbar`, with an explicit search region, filters, result summary, and bulk actions. | | 4 | Repeated domain fact and statistic grids | `campaign-header-grid` (8), `review-flow-fact-grid` (6), `postbox-form-grid` (5), plus smaller families | Compare semantics before promotion. Some can use `ContentGrid` or `DescriptionList`; others intentionally own unequal tracks or workflow visualization. |
| 5 | Dialog sizing exceptions | Dialog consumers still use 62 module class tokens across 53 files; 26 module/Core CSS files contain dialog/modal width declarations needing review | Migrate standard widths to `Dialog size`; retain only documented full-canvas, preview, chooser, and domain-editor exceptions. |
| 6 | State and collection anatomy | Loading components exist, but empty/error/retry/action placement is not yet one composition | A composable `StatePanel`/`CollectionState` covering empty, recoverable error, permission block, partial result, and next action without hiding domain consequences. |
The other custom grids are mostly bounded domain visualizations or unequal-track The other custom grids are mostly bounded domain visualizations or unequal-track
editors: conflict mappings, import mapping, campaign review facts, charts, editors: conflict mappings, import mapping, campaign review facts, charts,
@@ -45,8 +59,8 @@ created merely because two implementations both use CSS Grid.
## Next Audit ## Next Audit
The next bounded slice should implement the metric and description-list The next bounded slice should compare whole-surface empty states across list,
contracts, migrate their consumers, and then classify dialog width declarations detail, permission, capability, and recoverable-error contexts and promote only
as standard-size migrations or registered exceptions. After that, compare their shared anatomy. Filter/search composition follows after its live versus
filter/search rows before defining their composition; their accessibility and submitted filtering and bulk-selection behavior is explicit. Assignment grids
bulk-selection behavior matter more than visual similarity. remain deferred until their permission and policy variants are understood.
+116 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Enforce Core ownership of repeated WebUI toolbar, grid and dialog anatomy.""" """Enforce Core ownership of repeated WebUI layout and dialog anatomy."""
from __future__ import annotations from __future__ import annotations
@@ -11,6 +11,7 @@ import sys
META_ROOT = pathlib.Path(__file__).resolve().parents[2] META_ROOT = pathlib.Path(__file__).resolve().parents[2]
REPOS_ROOT = META_ROOT.parent REPOS_ROOT = META_ROOT.parent
CORE_INDEX = pathlib.Path("govoplan-core/webui/src/index.ts") CORE_INDEX = pathlib.Path("govoplan-core/webui/src/index.ts")
DIALOG_WIDTH_EXCEPTIONS = META_ROOT / "tools/checks/shared-webui-dialog-width-exceptions.txt"
RAW_ELEMENT = re.compile( RAW_ELEMENT = re.compile(
r'<(?P<tag>div|span|header|section|form)\b(?P<attrs>[^>]*?)' r'<(?P<tag>div|span|header|section|form)\b(?P<attrs>[^>]*?)'
@@ -24,6 +25,9 @@ LEGACY_LAYOUT_TOKENS = {
"settings-grid", "settings-grid",
"admin-dialog", "admin-dialog",
"admin-dialog-wide", "admin-dialog-wide",
"admin-details-grid",
"detail-list",
"metric-grid",
} }
CENTRAL_COMPONENTS = { CENTRAL_COMPONENTS = {
"ActionToolbar": pathlib.Path( "ActionToolbar": pathlib.Path(
@@ -55,6 +59,15 @@ CENTRAL_COMPONENTS = {
"DialogSection": pathlib.Path( "DialogSection": pathlib.Path(
"govoplan-core/webui/src/components/DialogAnatomy.tsx" "govoplan-core/webui/src/components/DialogAnatomy.tsx"
), ),
"DescriptionList": pathlib.Path(
"govoplan-core/webui/src/components/DescriptionList.tsx"
),
"DescriptionItem": pathlib.Path(
"govoplan-core/webui/src/components/DescriptionList.tsx"
),
"MetricGrid": pathlib.Path(
"govoplan-core/webui/src/components/MetricGrid.tsx"
),
} }
REQUIRED_CONSUMERS = { REQUIRED_CONSUMERS = {
"ActionToolbar": ( "ActionToolbar": (
@@ -89,7 +102,42 @@ REQUIRED_CONSUMERS = {
pathlib.Path("govoplan-files/webui/src/features/files/components/FileShareDialog.tsx"), pathlib.Path("govoplan-files/webui/src/features/files/components/FileShareDialog.tsx"),
pathlib.Path("govoplan-templates/webui/src/features/templates/TemplatesPage.tsx"), pathlib.Path("govoplan-templates/webui/src/features/templates/TemplatesPage.tsx"),
), ),
"DescriptionList": (
pathlib.Path("govoplan-access/webui/src/features/admin/UsersPanel.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/CampaignReportPage.tsx"),
pathlib.Path("govoplan-docs/webui/src/features/docs/DocsPage.tsx"),
pathlib.Path("govoplan-policy/webui/src/features/policy/ViewPoliciesPanel.tsx"),
),
"MetricGrid": (
pathlib.Path("govoplan-core/webui/src/features/dashboard/DashboardPage.tsx"),
pathlib.Path("govoplan-admin/webui/src/features/admin/ModuleManagementPanel.tsx"),
pathlib.Path("govoplan-campaign/webui/src/features/operator/OperatorQueuePage.tsx"),
pathlib.Path("govoplan-notifications/webui/src/features/notifications/NotificationSummaryWidget.tsx"),
),
} }
LEGACY_CSS_SELECTOR = re.compile(
r"(?<![-\w])\.(?:admin-details-grid|detail-list|metric-grid)(?![-\w])"
)
CSS_BLOCK = re.compile(r"([^{}]+)\{([^{}]*)\}")
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
DIALOG_CLASS = re.compile(r"\.([A-Za-z0-9_-]*(?:dialog|modal)[A-Za-z0-9_-]*)", re.IGNORECASE)
DIALOG_WIDTH = re.compile(r"(?:^|;)\s*(?:width|max-width)\s*:", re.MULTILINE)
DIALOG_WIDTH_VALUE = re.compile(
r"(?:^|;)\s*(?:width|max-width)\s*:\s*([^;]+)", re.MULTILINE
)
STANDARD_DIALOG_WIDTH = re.compile(r"\b(?:460|560|680|1040|1440)px\b")
DIALOG_INTERNAL_SUFFIXES = (
"-actions",
"-body",
"-close",
"-content",
"-field",
"-fields",
"-footer",
"-form",
"-header",
"-title",
)
def source_paths() -> list[pathlib.Path]: def source_paths() -> list[pathlib.Path]:
@@ -101,6 +149,15 @@ def source_paths() -> list[pathlib.Path]:
return paths return paths
def css_paths() -> list[pathlib.Path]:
paths: list[pathlib.Path] = []
for repository in sorted(REPOS_ROOT.glob("govoplan*")):
source_root = repository / "webui" / "src"
if source_root.is_dir():
paths.extend(sorted(source_root.rglob("*.css")))
return paths
def relative(path: pathlib.Path) -> pathlib.Path: def relative(path: pathlib.Path) -> pathlib.Path:
return path.relative_to(REPOS_ROOT) return path.relative_to(REPOS_ROOT)
@@ -123,9 +180,47 @@ def raw_reason(classes: str) -> str | None:
return None return None
def normalized_css_selector(selector: str) -> str:
return " ".join(selector.split())
def dialog_width_exceptions(styles: dict[pathlib.Path, str]) -> dict[str, str]:
exceptions: dict[str, str] = {}
central_dialog_styles = pathlib.Path("govoplan-core/webui/src/styles/dialogs.css")
for path, content in styles.items():
if path == central_dialog_styles:
continue
without_comments = CSS_COMMENT.sub("", content)
for match in CSS_BLOCK.finditer(without_comments):
selector = normalized_css_selector(match.group(1))
declarations = match.group(2)
if not DIALOG_WIDTH.search(declarations):
continue
dialog_classes = DIALOG_CLASS.findall(selector)
if not dialog_classes:
continue
if all(name.lower().endswith(DIALOG_INTERNAL_SUFFIXES) for name in dialog_classes):
continue
signature = f"{path}|{selector}"
exceptions[signature] = declarations
return exceptions
def exception_baseline() -> set[str]:
if not DIALOG_WIDTH_EXCEPTIONS.exists():
return set()
return {
line.strip()
for line in DIALOG_WIDTH_EXCEPTIONS.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
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}
styles = css_paths()
style_text = {relative(path): path.read_text(encoding="utf-8") for path in styles}
errors: list[str] = [] errors: list[str] = []
for path, content in source_text.items(): for path, content in source_text.items():
@@ -138,6 +233,24 @@ def main() -> int:
f"Raw {match.group('tag')} repeats shared anatomy ({reason}): {path}:{line}" f"Raw {match.group('tag')} repeats shared anatomy ({reason}): {path}:{line}"
) )
for path, content in style_text.items():
legacy_match = LEGACY_CSS_SELECTOR.search(CSS_COMMENT.sub("", content))
if legacy_match:
line = content.count("\n", 0, legacy_match.start()) + 1
errors.append(f"Legacy shared layout selector is not allowed: {path}:{line}")
dialog_exceptions = dialog_width_exceptions(style_text)
baseline = exception_baseline()
for signature in sorted(set(dialog_exceptions) - baseline):
errors.append(f"Unreviewed local dialog width; use Dialog size or register a justified exception: {signature}")
for signature in sorted(baseline - set(dialog_exceptions)):
errors.append(f"Stale dialog width exception can be removed: {signature}")
for signature, declarations in sorted(dialog_exceptions.items()):
width_values = " ".join(DIALOG_WIDTH_VALUE.findall(declarations))
standard = STANDARD_DIALOG_WIDTH.search(width_values)
if standard:
errors.append(f"Local dialog width duplicates Core size {standard.group(0)}: {signature}")
for name, owner in CENTRAL_COMPONENTS.items(): for name, owner in CENTRAL_COMPONENTS.items():
definition = re.compile( definition = re.compile(
rf"\b(?:function|class)\s+{name}\b|\bconst\s+{name}\s*=" rf"\b(?:function|class)\s+{name}\b|\bconst\s+{name}\s*="
@@ -178,9 +291,9 @@ def main() -> int:
migrated = ", ".join( migrated = ", ".join(
f"{name}={usage_counts[name]} files" f"{name}={usage_counts[name]} files"
for name in ("ActionToolbar", "ContentGrid", "FormGrid", "FormSection", "DialogForm", "DialogSection") for name in ("ActionToolbar", "ContentGrid", "FormGrid", "FormSection", "DialogForm", "DialogSection", "MetricGrid", "DescriptionList")
) )
print(f"Shared WebUI primitive contract passed: {migrated}; no raw legacy anatomy.") print(f"Shared WebUI primitive contract passed: {migrated}; {len(dialog_exceptions)} reviewed dialog width exceptions; no raw legacy anatomy.")
return 0 return 0
@@ -0,0 +1,67 @@
# Reviewed module-specific Dialog and modal width selectors.
#
# Standard Core widths (460/560/680/1040/1440px) are never valid here.
# Remove an entry when its consumer moves to Dialog size; additions require
# an explicit design review and an inventory explanation.
govoplan-addresses/webui/src/styles/addresses.css|.address-governance-dialog .dialog-panel
govoplan-addresses/webui/src/styles/addresses.css|.address-import-dialog
govoplan-addresses/webui/src/styles/addresses.css|.address-sync-dialog
govoplan-addresses/webui/src/styles/addresses.css|.dialog-panel.address-contact-dialog
govoplan-addresses/webui/src/styles/addresses.css|.dialog-panel.address-member-dialog
govoplan-addresses/webui/src/styles/addresses.css|.dialog-panel.address-quality-dialog, .address-quality-dialog .dialog-panel
govoplan-approvals/webui/src/styles/approvals.css|.approval-request-dialog, .approval-template-dialog
govoplan-approvals/webui/src/styles/approvals.css|.approval-template-history-dialog
govoplan-calendar/webui/src/styles/calendar.css|.calendar-delete-dialog
govoplan-calendar/webui/src/styles/calendar.css|.calendar-migration-dialog
govoplan-calendar/webui/src/styles/calendar.css|.calendar-outbox-dialog
govoplan-calendar/webui/src/styles/calendar.css|.calendar-vevent-dialog
govoplan-campaign/webui/src/styles/campaign-workspace.css|.attachment-linking-detail-modal
govoplan-campaign/webui/src/styles/campaign-workspace.css|.attachment-rules-modal
govoplan-campaign/webui/src/styles/campaign-workspace.css|.campaign-content-library-dialog
govoplan-campaign/webui/src/styles/campaign-workspace.css|.campaign-copy-dialog
govoplan-campaign/webui/src/styles/campaign-workspace.css|.campaign-schedule-dialog
govoplan-campaign/webui/src/styles/campaign-workspace.css|.dialog-panel-wide
govoplan-campaign/webui/src/styles/campaign-workspace.css|.message-preview-modal
govoplan-campaign/webui/src/styles/campaign-workspace.css|.recipient-address-editor-modal
govoplan-campaign/webui/src/styles/campaign-workspace.css|.recipient-import-modal
govoplan-campaign/webui/src/styles/campaign-workspace.css|.template-action-dialog
govoplan-campaign/webui/src/styles/campaign-workspace.css|.template-expression-dialog
govoplan-campaign/webui/src/styles/campaign-workspace.css|.template-preview-modal
govoplan-cases/webui/src/styles/cases.css|.case-decision-dialog
govoplan-cases/webui/src/styles/cases.css|.case-share-dialog
govoplan-committee/webui/src/styles/committee.css|.committee-ballot-dialog
govoplan-committee/webui/src/styles/committee.css|.committee-record-dialog
govoplan-core/webui/src/styles/components.css|.dialog-panel.wysiwyg-editor-dialog
govoplan-core/webui/src/styles/components.css|.guided-config-dialog
govoplan-core/webui/src/styles/components.css|.password-generator-dialog
govoplan-core/webui/src/styles/components.css|.unsaved-changes-dialog
govoplan-core/webui/src/styles/layout.css|.concurrency-conflict-dialog
govoplan-dashboard/webui/src/styles/dashboard.css|.dashboard-widget-config-dialog
govoplan-dataflow/webui/src/styles/dataflow.css|.dataflow-decision-dialog
govoplan-dataflow/webui/src/styles/dataflow.css|.dataflow-definition-dialog
govoplan-dataflow/webui/src/styles/dataflow.css|.dataflow-run-dialog
govoplan-dataflow/webui/src/styles/dataflow.css|.dataflow-source-dialog
govoplan-dataflow/webui/src/styles/dataflow.css|.dataflow-triggers-dialog
govoplan-datasources/webui/src/styles/datasources.css|.datasources-add-dialog
govoplan-datasources/webui/src/styles/datasources.css|.datasources-governance-dialog
govoplan-dist-lists/webui/src/styles/dist-lists.css|.dist-lists-entry-dialog
govoplan-dist-lists/webui/src/styles/dist-lists.css|.dist-lists-explanation-dialog
govoplan-files/webui/src/styles/file-manager.css|.file-dialog
govoplan-files/webui/src/styles/file-manager.css|.file-dialog:has(.archive-preview)
govoplan-files/webui/src/styles/file-manager.css|.file-dialog:has(.connector-sync-grid)
govoplan-files/webui/src/styles/file-manager.css|.file-dialog:has(.file-share-dialog-content)
govoplan-files/webui/src/styles/file-manager.css|.managed-file-chooser-dialog
govoplan-forms-runtime/webui/src/styles/forms-runtime.css|.form-intake-dialog
govoplan-forms/webui/src/styles/forms.css|.form-definition-dialog
govoplan-organizations/webui/src/styles/organizations.css|.organization-upgrade-dialog
govoplan-postbox/webui/src/styles/postbox.css|.postbox-dialog
govoplan-postbox/webui/src/styles/postbox.css|.postbox-message-dialog
govoplan-postbox/webui/src/styles/postbox.css|.postbox-template-dialog
govoplan-projects/webui/src/styles/projects.css|.project-editor-dialog
govoplan-records/webui/src/styles/records.css|.records-dialog
govoplan-reporting/webui/src/styles/reporting.css|.reporting-drill-dialog
govoplan-views/webui/src/styles/views.css|.views-assignment-dialog
govoplan-workflow/webui/src/styles/workflow.css|.workflow-definition-dialog
govoplan-workflow/webui/src/styles/workflow.css|.workflow-runs-dialog
govoplan-workflow/webui/src/styles/workflow.css|.workflow-standard-comparison-dialog