diff --git a/docs/architecture/INTERFACE_PATTERN_LANGUAGE.md b/docs/architecture/INTERFACE_PATTERN_LANGUAGE.md index 5aa92d8..0654186 100644 --- a/docs/architecture/INTERFACE_PATTERN_LANGUAGE.md +++ b/docs/architecture/INTERFACE_PATTERN_LANGUAGE.md @@ -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 diff --git a/docs/evidence/SHARED_WEBUI_PRIMITIVE_INVENTORY.md b/docs/evidence/SHARED_WEBUI_PRIMITIVE_INVENTORY.md new file mode 100644 index 0000000..0b5edbb --- /dev/null +++ b/docs/evidence/SHARED_WEBUI_PRIMITIVE_INVENTORY.md @@ -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. diff --git a/tools/checks/check-focused.sh b/tools/checks/check-focused.sh index 4eef100..3eb6f99 100644 --- a/tools/checks/check-focused.sh +++ b/tools/checks/check-focused.sh @@ -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 diff --git a/tools/checks/check-shared-webui-primitives.py b/tools/checks/check-shared-webui-primitives.py new file mode 100644 index 0000000..67c1665 --- /dev/null +++ b/tools/checks/check-shared-webui-primitives.py @@ -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'<(?Pdiv|span|header|section|form)\b(?P[^>]*?)' + r'\bclassName="(?P[^"]+)"', + 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 "