Enforce shared WebUI primitive adoption
This commit is contained in:
@@ -102,10 +102,31 @@ Migration is incremental and enforceable:
|
||||
5. promote the next repeated structure only after its variants and extension
|
||||
points are understood.
|
||||
|
||||
The intended next structural contracts are responsive page toolbar, content
|
||||
grid, form-section layout, dialog body/footer layout and shared empty/error
|
||||
state composition. Their APIs must remain composable; a central component is
|
||||
not a single oversized page template.
|
||||
The current structural vocabulary is:
|
||||
|
||||
- `ActionToolbar`, `ToolbarGroup`, and `ToolbarSpacer` own action alignment,
|
||||
density, grouping, accessible toolbar naming, help identity, and responsive
|
||||
wrapping. Modules may add surface padding or domain-specific presentation;
|
||||
they do not recreate the flex/wrap skeleton.
|
||||
- `ContentGrid`, `FormGrid`, `FormLayout`, and `GridItem` own equal-column
|
||||
geometry, standard gaps, alignment, spans, native form semantics, and named
|
||||
responsive collapse points. A module-local grid remains appropriate only
|
||||
when unequal tracks or domain visualization semantics are material.
|
||||
- `FormSection` owns form-section heading, description, actions, content flow,
|
||||
separation, and panel presentation. It does not own field values,
|
||||
validation, permissions, or domain wording.
|
||||
- `Dialog` owns size and administration variants, body padding, description,
|
||||
notices, and fixed footer placement. `DialogActions`, `DialogForm`, and
|
||||
`DialogSection` own the footer action flow, native form flow, and body
|
||||
grouping used inside it. Modules compose fields and consequences rather than
|
||||
recreating dialog anatomy.
|
||||
|
||||
Raw toolbar tags, the former generic grid classes, raw dialog-form wrappers,
|
||||
and module-local definitions of these contracts are rejected by the focused
|
||||
workspace checks. The next promotion candidates are metric groups,
|
||||
description/property lists, filter/search composition, responsive data
|
||||
summaries, and unified empty/error state composition. Their APIs must remain
|
||||
composable; a central component is not a single oversized page template.
|
||||
|
||||
## Surface Archetypes
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Shared WebUI Primitive Inventory
|
||||
|
||||
This 2026-08-18 inventory records the implementation state after the first
|
||||
product-wide toolbar, grid, form-section, and dialog-anatomy migration. It is
|
||||
evidence for prioritization, not a substitute for the normative
|
||||
[interface pattern language](../architecture/INTERFACE_PATTERN_LANGUAGE.md).
|
||||
|
||||
## Implemented And Enforced
|
||||
|
||||
| Contract | Adoption evidence | Ownership now enforced |
|
||||
| --- | ---: | --- |
|
||||
| `ActionToolbar` and groups | 39 source files | Raw module-prefixed toolbar elements and local toolbar component definitions are rejected. |
|
||||
| `ContentGrid` | 15 source files | Former `dashboard-grid` and `settings-grid` wrappers are rejected. |
|
||||
| `FormGrid` and `FormLayout` | 53 source files | Former `form-grid` and `admin-form-grid` wrappers are rejected; equal-column dialog and editor grids were migrated even when they had module-prefixed names. |
|
||||
| `FormSection` | 2 representative source files | The reusable section API, variants, responsive action placement, and heading anatomy are Core-owned; broader adoption is incremental. |
|
||||
| `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. |
|
||||
| `DialogSection` | 6 source files | Repeated dialog field/content grouping is available as a shared primitive. |
|
||||
|
||||
`tools/checks/check-shared-webui-primitives.py` verifies Core exports and
|
||||
ownership, representative consumers, the absence of the retired raw anatomy,
|
||||
and composition of every `Dialog` footer through `DialogActions`.
|
||||
|
||||
## Remaining Promotion Candidates
|
||||
|
||||
The post-migration scan found 101 uses of 30 non-Core grid class names across
|
||||
54 files. Most are intentionally domain-specific; repeated groups identify the
|
||||
next useful primitives:
|
||||
|
||||
| Priority | Remaining pattern | Evidence | Proposed contract |
|
||||
| --- | --- | ---: | --- |
|
||||
| 1 | Metric-card groups | `metric-grid`: 29 uses in 25 files | `MetricGrid` or `MetricGroup` owning 1–4/auto-fit columns, compact spacing, inset mode, and narrow stacking around the existing `MetricCard`. |
|
||||
| 2 | Property/description lists | `admin-details-grid`: 13 uses in 11 files | Semantic `DescriptionList` with compact/default density, 1–2 columns, wrapping, and narrow collapse. |
|
||||
| 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`. |
|
||||
| 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. |
|
||||
| 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
|
||||
editors: conflict mappings, import mapping, campaign review facts, charts,
|
||||
calendar time views, connector synchronization, records lists, and definition
|
||||
editors. They should remain module-owned unless a second domain demonstrates
|
||||
the same semantics and interaction contract. A shared primitive should not be
|
||||
created merely because two implementations both use CSS Grid.
|
||||
|
||||
## Next Audit
|
||||
|
||||
The next bounded slice should implement the metric and description-list
|
||||
contracts, migrate their consumers, and then classify dialog width declarations
|
||||
as standard-size migrations or registered exceptions. After that, compare
|
||||
filter/search rows before defining their composition; their accessibility and
|
||||
bulk-selection behavior matter more than visual similarity.
|
||||
@@ -90,6 +90,7 @@ PY
|
||||
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_addresses.backend.manifest; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
||||
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-layouts.py"
|
||||
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-primitives.py"
|
||||
"$PYTHON" -m unittest tests.test_module_system
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-connectors/tests
|
||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-datasources/tests
|
||||
@@ -116,6 +117,7 @@ PY
|
||||
"$PYTHON" -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
||||
|
||||
cd "$ROOT/webui"
|
||||
"$NPM" run test:layout-primitives
|
||||
"$NPM" run test:mail-components
|
||||
"$NPM" run test:module-capabilities
|
||||
"$NPM" run test:module-permutations
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce Core ownership of repeated WebUI toolbar, grid and dialog anatomy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
META_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
REPOS_ROOT = META_ROOT.parent
|
||||
CORE_INDEX = pathlib.Path("govoplan-core/webui/src/index.ts")
|
||||
|
||||
RAW_ELEMENT = re.compile(
|
||||
r'<(?P<tag>div|span|header|section|form)\b(?P<attrs>[^>]*?)'
|
||||
r'\bclassName="(?P<classes>[^"]+)"',
|
||||
re.DOTALL,
|
||||
)
|
||||
LEGACY_LAYOUT_TOKENS = {
|
||||
"form-grid",
|
||||
"admin-form-grid",
|
||||
"dashboard-grid",
|
||||
"settings-grid",
|
||||
"admin-dialog",
|
||||
"admin-dialog-wide",
|
||||
}
|
||||
CENTRAL_COMPONENTS = {
|
||||
"ActionToolbar": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/ActionToolbar.tsx"
|
||||
),
|
||||
"ToolbarGroup": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/ActionToolbar.tsx"
|
||||
),
|
||||
"ToolbarSpacer": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/ActionToolbar.tsx"
|
||||
),
|
||||
"ContentGrid": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/ContentGrid.tsx"
|
||||
),
|
||||
"FormGrid": pathlib.Path("govoplan-core/webui/src/components/ContentGrid.tsx"),
|
||||
"FormLayout": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/ContentGrid.tsx"
|
||||
),
|
||||
"GridItem": pathlib.Path("govoplan-core/webui/src/components/ContentGrid.tsx"),
|
||||
"FormSection": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/FormSection.tsx"
|
||||
),
|
||||
"DialogActions": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/DialogAnatomy.tsx"
|
||||
),
|
||||
"DialogForm": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/DialogAnatomy.tsx"
|
||||
),
|
||||
"DialogSection": pathlib.Path(
|
||||
"govoplan-core/webui/src/components/DialogAnatomy.tsx"
|
||||
),
|
||||
}
|
||||
REQUIRED_CONSUMERS = {
|
||||
"ActionToolbar": (
|
||||
pathlib.Path("govoplan-core/webui/src/components/WysiwygEditor.tsx"),
|
||||
pathlib.Path("govoplan-calendar/webui/src/features/calendar/CalendarPage.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"),
|
||||
),
|
||||
"ContentGrid": (
|
||||
pathlib.Path("govoplan-core/webui/src/features/settings/SettingsPage.tsx"),
|
||||
pathlib.Path("govoplan-campaign/webui/src/features/campaigns/GlobalSettingsPage.tsx"),
|
||||
pathlib.Path("govoplan-notifications/webui/src/features/notifications/NotificationSettingsPanel.tsx"),
|
||||
),
|
||||
"FormGrid": (
|
||||
pathlib.Path("govoplan-core/webui/src/components/mail/MailServerSettingsPanel.tsx"),
|
||||
pathlib.Path("govoplan-calendar/webui/src/features/calendar/CalendarEventDialog.tsx"),
|
||||
pathlib.Path("govoplan-forms/webui/src/features/forms/FormDefinitionDialog.tsx"),
|
||||
pathlib.Path("govoplan-postbox/webui/src/features/postbox/PostboxAdminPanel.tsx"),
|
||||
),
|
||||
"FormSection": (
|
||||
pathlib.Path("govoplan-addresses/webui/src/features/addressbook/AddressBookPage.tsx"),
|
||||
pathlib.Path("govoplan-quick-access/webui/src/features/settings/QuickAccessSettingsPanel.tsx"),
|
||||
),
|
||||
"DialogForm": (
|
||||
pathlib.Path("govoplan-addresses/webui/src/features/addressbook/AddressBookPage.tsx"),
|
||||
pathlib.Path("govoplan-calendar/webui/src/features/calendar/CalendarEventDialog.tsx"),
|
||||
pathlib.Path("govoplan-records/webui/src/features/records/RecordsPage.tsx"),
|
||||
),
|
||||
"DialogSection": (
|
||||
pathlib.Path("govoplan-datasources/webui/src/features/datasources/DatasourcesPage.tsx"),
|
||||
pathlib.Path("govoplan-files/webui/src/features/files/components/FileShareDialog.tsx"),
|
||||
pathlib.Path("govoplan-templates/webui/src/features/templates/TemplatesPage.tsx"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def source_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("*.tsx")))
|
||||
return paths
|
||||
|
||||
|
||||
def relative(path: pathlib.Path) -> pathlib.Path:
|
||||
return path.relative_to(REPOS_ROOT)
|
||||
|
||||
|
||||
def raw_reason(classes: str) -> str | None:
|
||||
tokens = classes.split()
|
||||
legacy = sorted(set(tokens) & LEGACY_LAYOUT_TOKENS)
|
||||
if legacy:
|
||||
return f"legacy shared layout class {', '.join(legacy)}"
|
||||
toolbars = [
|
||||
token
|
||||
for token in tokens
|
||||
if token == "admin-toolbar-row" or token.endswith("-toolbar")
|
||||
]
|
||||
if toolbars:
|
||||
return f"raw toolbar class {', '.join(toolbars)}"
|
||||
dialog_forms = [token for token in tokens if token.endswith("dialog-form")]
|
||||
if dialog_forms:
|
||||
return f"raw dialog form class {', '.join(dialog_forms)}"
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sources = source_paths()
|
||||
source_text = {relative(path): path.read_text(encoding="utf-8") for path in sources}
|
||||
errors: list[str] = []
|
||||
|
||||
for path, content in source_text.items():
|
||||
for match in RAW_ELEMENT.finditer(content):
|
||||
reason = raw_reason(match.group("classes"))
|
||||
if reason is None:
|
||||
continue
|
||||
line = content.count("\n", 0, match.start()) + 1
|
||||
errors.append(
|
||||
f"Raw {match.group('tag')} repeats shared anatomy ({reason}): {path}:{line}"
|
||||
)
|
||||
|
||||
for name, owner in CENTRAL_COMPONENTS.items():
|
||||
definition = re.compile(
|
||||
rf"\b(?:function|class)\s+{name}\b|\bconst\s+{name}\s*="
|
||||
)
|
||||
for path, content in source_text.items():
|
||||
if path != owner and definition.search(content):
|
||||
errors.append(f"Module-local {name} definition is not allowed: {path}")
|
||||
|
||||
usage_counts: dict[str, int] = {}
|
||||
for name in CENTRAL_COMPONENTS:
|
||||
usage = re.compile(rf"<{name}\b")
|
||||
usage_counts[name] = sum(bool(usage.search(content)) for content in source_text.values())
|
||||
|
||||
for name, consumers in REQUIRED_CONSUMERS.items():
|
||||
for path in consumers:
|
||||
absolute = REPOS_ROOT / path
|
||||
if not absolute.exists():
|
||||
continue
|
||||
if f"<{name}" not in absolute.read_text(encoding="utf-8"):
|
||||
errors.append(f"Required shared {name} consumer regressed: {path}")
|
||||
|
||||
core_index_path = REPOS_ROOT / CORE_INDEX
|
||||
if core_index_path.exists():
|
||||
core_index = core_index_path.read_text(encoding="utf-8")
|
||||
for name in CENTRAL_COMPONENTS:
|
||||
if not re.search(rf"\b{name}\b", core_index):
|
||||
errors.append(f"Core must export {name} from @govoplan/core-webui.")
|
||||
|
||||
dialog_path = REPOS_ROOT / "govoplan-core/webui/src/components/Dialog.tsx"
|
||||
if dialog_path.exists():
|
||||
dialog_text = dialog_path.read_text(encoding="utf-8")
|
||||
if "<DialogActions" not in dialog_text:
|
||||
errors.append("Every Core Dialog footer must compose DialogActions.")
|
||||
|
||||
if errors:
|
||||
print("\n".join(errors), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
migrated = ", ".join(
|
||||
f"{name}={usage_counts[name]} files"
|
||||
for name in ("ActionToolbar", "ContentGrid", "FormGrid", "FormSection", "DialogForm", "DialogSection")
|
||||
)
|
||||
print(f"Shared WebUI primitive contract passed: {migrated}; no raw legacy anatomy.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user