diff --git a/docs/architecture/INTERFACE_PATTERN_LANGUAGE.md b/docs/architecture/INTERFACE_PATTERN_LANGUAGE.md index 1638811..edcb341 100644 --- a/docs/architecture/INTERFACE_PATTERN_LANGUAGE.md +++ b/docs/architecture/INTERFACE_PATTERN_LANGUAGE.md @@ -51,6 +51,55 @@ rules: - Preserve a stable way back to the containing object and the broader system. - Do not let navigation, selection, or a view switch imply consent. +## Shared Component And Layout Architecture + +Core owns the reusable WebUI vocabulary; modules own domain composition and +behavior. Centralization follows four layers: + +| Layer | Owner | Examples | Rule | +| --- | --- | --- | --- | +| Foundation | Core | theme tokens, spacing, typography, focus and responsive breakpoints | Modules consume the contract and do not redefine it. | +| Primitives | Core | buttons, fields, dialogs, alerts, cards, tables, loading, empty and blocked states | A matching primitive is reused rather than copied locally. | +| Structural layouts | Core | page frame and header, action region, workspace panes, toolbars, grids, form sections and dialog anatomy | Layout owns geometry, scroll, responsive collapse and accessibility, but no domain decisions. | +| Domain compositions | Owning module | a campaign review, mailbox, records explorer or operations dashboard | Modules select shared pieces, bind data and permissions, and retain domain wording and consequences. | + +A component belongs in Core when it is used or expected in more than one +module and central ownership materially protects accessibility, responsive +behavior, localization, contextual help, theming, or interaction consistency. +A component stays module-owned when its API would otherwise encode a domain +entity, permission, workflow state, endpoint, or policy decision. Reuse does +not justify moving domain semantics into Core. + +`PageLayout` is the standard frame for headed workflow, dashboard, +configuration, monitoring and explanatory pages. It owns the scroll viewport, +content inset, sticky responsive header, title and description geometry, +route-action placement, transient page notices, loading boundary and page help +identity. `PageHeader` is the escape hatch for full-canvas archetypes that need +the same header contract but must own their workspace scroll. Specialized +layouts such as `AdminPageLayout` compose these lower-level Core contracts; +they do not repeat their markup or responsive CSS. + +Module CSS may arrange domain content inside a shared layout. It must not +override Core layout internals or copy the outer page, dialog, toolbar, form or +state skeleton under a module-prefixed name. If an archetype cannot be +expressed by the central API, extend the central contract or record a bounded +exception before introducing local structure. + +Migration is incremental and enforceable: + +1. inventory copied structures and register existing debt; +2. introduce the smallest domain-neutral Core contract with accessibility, + help, localization, theme and narrow-layout tests; +3. migrate representative Core and optional-module consumers; +4. reject new copies while removing registered debt in bounded module batches; +5. promote the next repeated structure only after its variants and extension + points are understood. + +The intended next structural contracts are workspace/split-pane layout, +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. + ## Surface Archetypes Choose an archetype from the task, then specialize it for the domain. A route @@ -101,7 +150,8 @@ one. creation panel. - Workflow, configuration, dashboard, and explanatory pages may use a heading. The heading names the task or scoped object and contains only route-level - actions. + actions. Use the Core `PageLayout` contract for the frame and `PageHeader` + only when a full-canvas archetype owns its own scrolling. - Put a collection-wide create action in the heading of the collection it affects. Use a short, specific label such as `Add` when the heading already names the object. Do not duplicate that action in a permanently visible side diff --git a/tools/checks/check-focused.sh b/tools/checks/check-focused.sh index 836f86f..b6eb423 100644 --- a/tools/checks/check-focused.sh +++ b/tools/checks/check-focused.sh @@ -89,6 +89,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" -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 diff --git a/tools/checks/check-shared-webui-layouts.py b/tools/checks/check-shared-webui-layouts.py new file mode 100644 index 0000000..b8b8758 --- /dev/null +++ b/tools/checks/check-shared-webui-layouts.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Keep raw module page frames from growing while shared layouts are adopted.""" + +from __future__ import annotations + +import pathlib +import re +import sys + + +META_ROOT = pathlib.Path(__file__).resolve().parents[2] +REPOS_ROOT = META_ROOT.parent +BASELINE_PATH = pathlib.Path(__file__).with_name("shared-webui-layout-baseline.txt") +RAW_PAGE_FRAME = 'className="content-pad workspace-data-page' +LOCAL_PAGE_LAYOUT = re.compile(r"\b(?:function|class|const)\s+PageLayout\b") +CENTRAL_LAYOUT = pathlib.Path("govoplan-core/webui/src/components/PageLayout.tsx") +REQUIRED_CONSUMERS = ( + pathlib.Path("govoplan-core/webui/src/components/admin/AdminPageLayout.tsx"), + pathlib.Path("govoplan-core/webui/src/features/dashboard/DashboardPage.tsx"), + pathlib.Path("govoplan-dashboard/webui/src/features/dashboard/DashboardPage.tsx"), + pathlib.Path("govoplan-ops/webui/src/features/ops/OpsPage.tsx"), +) + + +def baseline_paths() -> set[pathlib.Path]: + return { + pathlib.Path(line.strip()) + for line in BASELINE_PATH.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + + +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 main() -> int: + sources = source_paths() + source_text = {relative(path): path.read_text(encoding="utf-8") for path in sources} + raw_frames = {path for path, text in source_text.items() if RAW_PAGE_FRAME in text} + baseline = baseline_paths() + available_baseline = { + path for path in baseline if (REPOS_ROOT / path.parts[0]).is_dir() + } + errors: list[str] = [] + + unexpected = sorted(raw_frames - available_baseline) + if unexpected: + errors.append("New raw page frames must use @govoplan/core-webui PageLayout:") + errors.extend(f"- {path}" for path in unexpected) + + resolved = sorted(available_baseline - raw_frames) + if resolved: + errors.append("Remove migrated page frames from the shared-layout baseline:") + errors.extend(f"- {path}" for path in resolved) + + for path, text in source_text.items(): + if path != CENTRAL_LAYOUT and LOCAL_PAGE_LAYOUT.search(text): + errors.append(f"Module-local PageLayout definition is not allowed: {path}") + + for path in REQUIRED_CONSUMERS: + absolute_path = REPOS_ROOT / path + if not absolute_path.exists(): + continue + text = absolute_path.read_text(encoding="utf-8") + if "