feat: enforce shared UI foundations and pin reference journey
Dependency Audit / dependency-audit (push) Successful in 1m57s
Deployment Installer / deployment-installer (push) Successful in 6s
Security Audit / security-audit (push) Successful in 12m24s

This commit is contained in:
2026-08-18 21:32:25 +02:00
parent a1b80eda27
commit e2f505eeab
11 changed files with 445 additions and 64 deletions
@@ -41,12 +41,15 @@ The first production-shaped slice is implemented:
- the expanded left rail groups classified destinations while retaining
Dashboard and every authorized unclassified destination under More tools.
The remaining rollout is classification rather than a missing boundary: other
user-facing modules must announce their product areas and future bounded tools,
reference journeys need browser accessibility evidence, and richer active-object
context should be added only through a separately versioned launch-context
contract. Until classification is complete, authorized unclassified routes
remain visible rather than disappearing.
The baseline classification is now manifest-declared for every ordinary
user-facing module and enforced by the workspace manifest check. A separately
versioned launch-context contract carries bounded active-object, acting,
temporal, View and return references into full-page Quick Access fallbacks;
Cases publishes the first active-object reference. The remaining rollout is to
add useful bounded tools and active-object publishers only where a maintained
journey benefits, and to extend browser evidence to a pinned reference
composition. Authorized global and technical routes remain visible through
their dedicated shell entry or **All available tools**.
## Quick Access Boundary
@@ -150,6 +153,25 @@ Familiar product nouns such as Calendar, Mail or Files may remain directly
pinned. The objective is not to hide every module name; it is to prevent
repository topology from determining a person's workflow.
The initial module classification is deliberately outcome-oriented:
| Product area | Contributing user-facing modules |
| --- | --- |
| Work | Approvals, Projects, Tasks, Workflow |
| Services and Cases | Cases, Forms, Forms Runtime, Portal |
| Records and Documents | Files, Records, Templates |
| Communication | Campaigns, Distribution Lists, Mail, Notifications, Postbox |
| Meetings and Decisions | Calendar, Committee, Scheduling, Voting |
| Data and Assurance | Dataflow, Datasources, Reporting, Risk Compliance |
| People and Responsibility | Address Book, IDM, Organizations |
Dashboard, Search, Documentation and Quick Access remain global shell
affordances. Access, Administration, Audit, Encryption, Identity Trust,
Operations, Policy, Tenancy and Views remain administrative or platform
surfaces available through their dedicated entry point or **All available
tools**. The manifest-shape check enforces both this explicit exception set and
the shared label, icon, description and ordering of every canonical area.
## Full Access And Provenance
The existing permission-derived module rail remains available as **All
@@ -39,6 +39,15 @@ identify the journey it improves, or provide security, operability, recovery,
accessibility, or usability evidence that those journeys require. Work that
does neither stays in the backlog until a concrete consumer exists.
The maintained service-to-decision scenario is the German resident parking
permit (`Anwohnerparkausweis`), pinned by
`tests/fixtures/resident_parking_permit_journey.json`. It replaces generic
permit examples as acceptance evidence and fixes the service, exact Form
revision, digital and assisted intake, Case and Workflow handoff, formal
Decision, Postbox delivery, and Records target. Changing this flagship scenario
is a product decision; implementations may add further scenarios without
weakening or silently replacing its acceptance gates.
The Records vertical now supplies the journey's native file plan, immutable
record and item revisions, chronology, close/reopen, retention calculation,
holds, appraisal, independent disposition approval, recovery-ledger evidence,
@@ -9,6 +9,14 @@ resolution -> approval/deliberation -> formal Decision -> observed delivery
effect -> record and review references
```
The maintained concrete scenario is a German resident parking permit
(`Anwohnerparkausweis`). Its versioned fixture is
`tests/fixtures/resident_parking_permit_journey.json`. It pins the service,
exact Form revision, resident inputs, digital and assisted channels, Case type,
human review handoff, formal outcome, Postbox delivery channel, and Records
filing/retention target. Generic permit wording is no longer acceptance
evidence for this package.
An installed Forms and Forms Runtime pair adds an alternative governed entry
path before case/workflow handoff:
@@ -45,6 +45,11 @@
"reference": "packages/product/service-to-decision/README.md",
"summary": "Defines the package boundary, authority path, recovery contract, and known operational limits."
},
{
"kind": "target_test",
"reference": "tests/fixtures/resident_parking_permit_journey.json",
"summary": "Pins the resident parking permit actors, channels, exact inputs, work item, formal outcome, filing target, and remaining manual acceptance gates."
},
{
"kind": "target_test",
"reference": "tests/test_institutional_governance_journey.py",
+67
View File
@@ -0,0 +1,67 @@
{
"id": "resident-parking-permit-berlin-style-reference",
"title": "Resident parking permit",
"title_de": "Anwohnerparkausweis",
"locale": "de-DE",
"service": {
"object_id": "resident-parking-permit",
"key": "resident_parking_permit.apply",
"version": "6",
"audience": "resident",
"required_evidence_types": [
"application",
"identity",
"primary_residence",
"vehicle_registration"
],
"channels": ["portal", "assisted"]
},
"form": {
"object_id": "resident-parking-permit-application",
"version": "3",
"fields": {
"applicant_name": "Ada Lovelace",
"residence_address": "Musterstrasse 17, 10115 Berlin",
"licence_plate": "B-AL 1843"
}
},
"case": {
"type_key": "resident-parking-permit-application",
"number": "RPP-2026-0001",
"initial_status": "intake",
"decided_status": "decided",
"deadline_days": 30
},
"workflow": {
"definition_name": "Resident parking permit decision",
"work_item_title": "Decide the resident parking permit application",
"instructions": "Review identity, primary residence, vehicle evidence, and the effective local rule before recording the decision."
},
"decision": {
"type": "resident-parking-permit",
"operative_result": "Resident parking permit granted.",
"reasoning": "Identity, primary residence, vehicle registration, and the effective local rule were verified.",
"delivery_channel": "postbox",
"remedy": "review:administrative-court"
},
"records": {
"file_plan_key": "traffic.resident-parking-permits",
"retention_policy_ref": "records:resident-parking-permit"
},
"acceptance": {
"automated": [
"The published service and exact form revision drive digital intake.",
"An idempotent replay returns the same persisted submission.",
"The human review handoff survives a database-session restart and remains visible in Tasks until completion.",
"The formal decision retains party, mandate, legal-basis, evidence, delivery, review, and exact revision references.",
"Forms Runtime, Cases, and Decisions can expose exact snapshots for explicit eAkte filing."
],
"manual_or_target": [
"Complete the digital journey with keyboard and screen reader at desktop and mobile widths.",
"Repeat intake through an assisted-channel operator without weakening identity, purpose, or provenance.",
"Verify the configured Postbox or external delivery provider, including unknown outcome and reconciliation.",
"Restore the pinned composition and reconstruct the exact form, case, decision, delivery evidence, and eAkte chronology.",
"Transfer through a named archive profile and retain independently signed target evidence."
]
}
}
+39 -29
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
import json
from pathlib import Path
import unittest
from sqlalchemy import create_engine
@@ -66,6 +68,11 @@ from govoplan_services.backend.service import SqlServiceDefinitionProvider, reco
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
JOURNEY = json.loads(
(Path(__file__).parent / "fixtures/resident_parking_permit_journey.json").read_text(
encoding="utf-8"
)
)
def _reference(
@@ -177,31 +184,31 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
service = ServiceDefinition(
reference=_reference(
"service",
"permit-service",
JOURNEY["service"]["object_id"],
owner="services",
version="5",
version=JOURNEY["service"]["version"],
),
key="permit.apply",
key=JOURNEY["service"]["key"],
temporal=TemporalRevision(
revision="5",
revision=JOURNEY["service"]["version"],
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=30),
recorded_at=NOW - timedelta(days=2),
change_reason="Service published.",
),
title="Apply for a permit",
title=JOURNEY["title"],
audience=("resident",),
legal_bases=(legal_basis,),
required_evidence_types=("application", "identity"),
channels=("portal", "postbox"),
required_evidence_types=tuple(JOURNEY["service"]["required_evidence_types"]),
channels=tuple(JOURNEY["service"]["channels"]) + ("postbox",),
responsible_organization_ref=organization,
responsible_function_ref=function,
mandate_ref=mandate_ref,
jurisdiction_refs=(jurisdiction,),
bindings=(
ServiceBinding("case", "permit-application"),
ServiceBinding("workflow", "workflow:permit-review"),
ServiceBinding("result", "decision:permit"),
ServiceBinding("case", JOURNEY["case"]["type_key"]),
ServiceBinding("workflow", "workflow:resident-parking-permit-review"),
ServiceBinding("result", f"decision:{JOURNEY['decision']['type']}"),
),
remedy_refs=("review:administrative-court",),
publication_state="published",
@@ -230,37 +237,40 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
upsert_case_status(
session,
principal,
status_key="intake",
status_key=JOURNEY["case"]["initial_status"],
label="Intake",
)
upsert_case_status(
session,
principal,
status_key="decided",
status_key=JOURNEY["case"]["decided_status"],
label="Decided",
category="decided",
)
upsert_case_type(
session,
principal,
type_key="permit-application",
label="Permit application",
initial_status_key="intake",
allowed_status_keys=("intake", "decided"),
type_key=JOURNEY["case"]["type_key"],
label=JOURNEY["title"],
initial_status_key=JOURNEY["case"]["initial_status"],
allowed_status_keys=(
JOURNEY["case"]["initial_status"],
JOURNEY["case"]["decided_status"],
),
)
case_record = create_case_from_intake(
session,
principal,
plan=intake,
case_number="PERMIT-2026-0001",
title="Permit application",
status_key=None,
case_number=JOURNEY["case"]["number"],
title=JOURNEY["title"],
status_key=JOURNEY["case"]["initial_status"],
opened_at=NOW,
recorded_at=NOW,
change_reason="Portal application received.",
idempotency_key="journey-case-create",
evidence_refs=(application_evidence,),
deadline_at=NOW + timedelta(days=30),
deadline_at=NOW + timedelta(days=JOURNEY["case"]["deadline_days"]),
)
applicant = _reference("party", "applicant", owner="parties")
@@ -333,7 +343,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
change_reason="Permit authority delegated.",
),
task_types=("committee.formal_decision",),
authority_types=("permit",),
authority_types=(JOURNEY["decision"]["type"],),
organization_unit_refs=(organization,),
function_refs=(function,),
jurisdiction_refs=(jurisdiction,),
@@ -380,7 +390,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
object_id="item-1",
revision=1,
state="deliberating",
title="Permit application",
title=JOURNEY["title"],
parent_id=meeting.object_id,
recorded_at=NOW,
change_reason="Agenda item entered deliberation.",
@@ -398,7 +408,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
object_id="vote-1",
revision=1,
state="closed",
title="Vote on permit application",
title=f"Vote on {JOURNEY['title'].lower()}",
parent_id=agenda.object_id,
recorded_at=NOW,
change_reason="Vote result accepted.",
@@ -445,7 +455,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
effective_at=NOW,
meeting_ref="meeting-1",
agenda_item_ref="item-1",
decision_type="permit",
decision_type=JOURNEY["decision"]["type"],
subject_refs=(case_record.reference,),
organization_unit_ref=organization,
function_ref=function,
@@ -461,8 +471,8 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
),
fact_evidence=(application_evidence, address_evidence),
legal_bases=(legal_basis,),
operative_result="Permit granted.",
reasoning="The application satisfies the effective rule.",
operative_result=JOURNEY["decision"]["operative_result"],
reasoning=JOURNEY["decision"]["reasoning"],
case_ref=case_record.reference,
jurisdiction_refs=(jurisdiction,),
party_refs=(applicant, representative),
@@ -475,7 +485,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
classification="restricted",
purposes=("permit-decision", "party-delivery"),
legal_basis_refs=("permit-law:3@2026-01",),
retention_policy_ref="records:permit",
retention_policy_ref=JOURNEY["records"]["retention_policy_ref"],
disclosure_state="partly_disclosable",
),
),
@@ -568,7 +578,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
get_case(session, principal, case_id="case-1"),
)
self.assertEqual(
"decided",
JOURNEY["case"]["decided_status"],
get_workspace_object(
session,
principal,
@@ -583,7 +593,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
self.assertEqual("confirmed", reconstruction["observed_effects"][0]["state"])
self.assertEqual("audit:delivery-1", reconstruction["observed_effects"][0]["audit_event_refs"][0])
self.assertEqual("application-1", reconstruction["fact_evidence"][0]["evidence_id"])
self.assertEqual("The application satisfies the effective rule.", reconstruction["reasoning"])
self.assertEqual(JOURNEY["decision"]["reasoning"], reconstruction["reasoning"])
self.assertEqual("review:administrative-court", reconstruction["review_refs"][0])
+52 -27
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import json
from pathlib import Path
from types import SimpleNamespace
import unittest
@@ -82,6 +84,11 @@ from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
JOURNEY = json.loads(
(Path(__file__).parent / "fixtures/resident_parking_permit_journey.json").read_text(
encoding="utf-8"
)
)
def _service() -> ServiceDefinition:
@@ -89,24 +96,24 @@ def _service() -> ServiceDefinition:
reference=InstitutionalReference(
kind="service",
owner_module="portal",
object_id="permit",
object_id=JOURNEY["service"]["object_id"],
tenant_id="tenant-1",
version="5",
version=JOURNEY["service"]["version"],
),
key="permit.apply",
key=JOURNEY["service"]["key"],
temporal=TemporalRevision(
revision="5",
revision=JOURNEY["service"]["version"],
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
),
title="Apply for a permit",
audience=("resident",),
required_evidence_types=("application",),
title=JOURNEY["title"],
audience=(JOURNEY["service"]["audience"],),
required_evidence_types=tuple(JOURNEY["service"]["required_evidence_types"]),
bindings=(
ServiceBinding("capability", CAPABILITY_CASES_SERVICE_INTAKE),
ServiceBinding("case", "permit-application"),
ServiceBinding("workflow", "workflow:permit-review"),
ServiceBinding("case", JOURNEY["case"]["type_key"]),
ServiceBinding("workflow", "workflow:resident-parking-permit-review"),
),
publication_state="published",
)
@@ -230,8 +237,14 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
self.assertTrue(entries[0].available)
self.assertIs(definition, entries[0].definition)
self.assertEqual(definition.reference, plan.service_ref)
self.assertEqual("5", plan.context.service_ref.version)
self.assertEqual("workflow:permit-review", plan.workflow_refs[0])
self.assertEqual(JOURNEY["service"]["version"], plan.context.service_ref.version)
self.assertEqual("workflow:resident-parking-permit-review", plan.workflow_refs[0])
def test_reference_fixture_names_remaining_manual_target_evidence(self) -> None:
self.assertEqual("Anwohnerparkausweis", JOURNEY["title_de"])
self.assertEqual("de-DE", JOURNEY["locale"])
self.assertEqual(5, len(JOURNEY["acceptance"]["automated"]))
self.assertEqual(5, len(JOURNEY["acceptance"]["manual_or_target"]))
def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
@@ -252,19 +265,19 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
reference=InstitutionalReference(
kind="form",
owner_module="forms",
object_id="permit-application",
object_id=JOURNEY["form"]["object_id"],
tenant_id="tenant-1",
version="3",
),
key="permit-application",
key=JOURNEY["form"]["object_id"],
temporal=TemporalRevision(
revision="3",
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
change_reason="Publish the permit application.",
change_reason="Publish the resident parking permit application.",
),
title="Permit application",
title=JOURNEY["title"],
fields=(
FormFieldDefinition(
key="applicant_name",
@@ -272,6 +285,18 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
required=True,
constraints={"min_length": 2},
),
FormFieldDefinition(
key="residence_address",
label="Primary residence address",
required=True,
constraints={"min_length": 5},
),
FormFieldDefinition(
key="licence_plate",
label="Vehicle licence plate",
required=True,
constraints={"min_length": 3},
),
),
publication_state="published",
allow_drafts=True,
@@ -286,18 +311,18 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
reference=InstitutionalReference(
kind="service",
owner_module="services",
object_id="permit",
object_id=JOURNEY["service"]["object_id"],
tenant_id="tenant-1",
version="6",
version=JOURNEY["service"]["version"],
),
key="permit.apply",
key=JOURNEY["service"]["key"],
temporal=TemporalRevision(
revision="6",
revision=JOURNEY["service"]["version"],
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
),
title="Apply for a permit",
title=JOURNEY["title"],
audience=("public",),
bindings=(binding,),
publication_state="published",
@@ -311,7 +336,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
reference=service.reference,
requested_at=NOW,
idempotency_key="portal-form-launch-1",
parameters={"applicant_name": "Ada Lovelace"},
parameters=JOURNEY["form"]["fields"],
)
replay = directory.launch_service(
session,
@@ -319,7 +344,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
reference=service.reference,
requested_at=NOW,
idempotency_key="portal-form-launch-1",
parameters={"applicant_name": "Ada Lovelace"},
parameters=JOURNEY["form"]["fields"],
)
instance = FormRuntimeService(registry).get_instance(
session,
@@ -348,7 +373,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
)
self.assertEqual(NOW, instance.definition_ref.valid_at)
self.assertEqual(service.reference, instance.service_ref)
self.assertEqual("Ada Lovelace", instance.values["applicant_name"])
self.assertEqual(JOURNEY["form"]["fields"], instance.values)
finally:
session.close()
engine.dispose()
@@ -392,7 +417,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Permit decision",
name=JOURNEY["workflow"]["definition_name"],
graph=WorkflowGraph(
nodes=[
WorkflowNode(
@@ -403,8 +428,8 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
id="review",
type="workflow.activity",
config={
"title": "Decide the permit application",
"instructions": "Review the filed evidence and record the decision.",
"title": JOURNEY["workflow"]["work_item_title"],
"instructions": JOURNEY["workflow"]["instructions"],
"assignee": "account:account-1",
"due_after": "2d",
},
@@ -464,7 +489,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
self.assertEqual(1, work.total)
self.assertEqual(step_id, work.items[0].id)
self.assertEqual(
"Decide the permit application",
JOURNEY["workflow"]["work_item_title"],
work.items[0].title,
)
self.assertTrue(work.items[0].action_url.startswith("/workflow?"))
+2
View File
@@ -91,6 +91,7 @@ PY
"$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" "$META_ROOT/tools/checks/check-shared-webui-foundations.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
@@ -121,6 +122,7 @@ cd "$ROOT/webui"
"$NPM" run test:mail-components
"$NPM" run test:module-capabilities
"$NPM" run test:module-permutations
"$NPM" run test:conformance
cd /mnt/DATA/git/govoplan-dataflow/webui
"$NPM" run test:structure
+117
View File
@@ -17,6 +17,70 @@ MODULE_NAME_PATTERN = re.compile(
r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
)
REQUIRED_DOCUMENTATION_TYPES = frozenset({"admin", "user"})
CANONICAL_PRODUCT_AREAS = {
"work": (
"i18n:govoplan-core.product_area.work",
"list-checks",
"i18n:govoplan-core.product_area.work_description",
10,
),
"services-cases": (
"i18n:govoplan-core.product_area.services_cases",
"landmark",
"i18n:govoplan-core.product_area.services_cases_description",
20,
),
"records-documents": (
"i18n:govoplan-core.product_area.records_documents",
"folder",
"i18n:govoplan-core.product_area.records_documents_description",
30,
),
"communication": (
"i18n:govoplan-core.product_area.communication",
"mail",
"i18n:govoplan-core.product_area.communication_description",
40,
),
"meetings-decisions": (
"i18n:govoplan-core.product_area.meetings_decisions",
"calendar",
"i18n:govoplan-core.product_area.meetings_decisions_description",
50,
),
"data-assurance": (
"i18n:govoplan-core.product_area.data_assurance",
"database-zap",
"i18n:govoplan-core.product_area.data_assurance_description",
60,
),
"people-responsibility": (
"i18n:govoplan-core.product_area.people_responsibility",
"users",
"i18n:govoplan-core.product_area.people_responsibility_description",
70,
),
}
# These surfaces are intentionally global, administrative, security-policy, or
# shell infrastructure. They remain discoverable through their dedicated shell
# affordance or through "All available tools" instead of a business area.
PRODUCT_AREA_EXEMPT_MODULES = frozenset(
{
"access",
"admin",
"audit",
"dashboard",
"docs",
"encryption",
"identity_trust",
"ops",
"policy",
"quick_access",
"search",
"tenancy",
"views",
}
)
def main() -> int:
@@ -113,6 +177,42 @@ def main() -> int:
)
continue
frontend = manifest.frontend
has_user_facing_surface = frontend is not None and bool(
frontend.routes
or frontend.public_routes
or frontend.nav_items
or frontend.settings_routes
)
if (
has_user_facing_surface
and not frontend.product_areas
and manifest.id not in PRODUCT_AREA_EXEMPT_MODULES
):
errors.append(
f"{repository_name}: user-facing module {manifest.id!r} has no "
"ProductAreaContribution and is not an explicit global/technical exemption"
)
if frontend is not None:
for contribution in frontend.product_areas:
expected = CANONICAL_PRODUCT_AREAS.get(contribution.id)
actual = (
contribution.label,
contribution.icon,
contribution.description,
contribution.order,
)
if expected is None:
errors.append(
f"{repository_name}: module {manifest.id!r} uses unknown product "
f"area {contribution.id!r}"
)
elif actual != expected:
errors.append(
f"{repository_name}: module {manifest.id!r} redefines canonical "
f"product area {contribution.id!r}; expected {expected!r}, found {actual!r}"
)
repository_root = manifest_path.parents[3]
if manifest.architecture is None:
if args.require_architecture:
@@ -142,6 +242,23 @@ def main() -> int:
print("\n".join(errors), file=sys.stderr)
return 1
contributed_product_areas = {
contribution.id
for manifest in manifests
if manifest.frontend is not None
for contribution in manifest.frontend.product_areas
}
missing_product_areas = sorted(
set(CANONICAL_PRODUCT_AREAS) - contributed_product_areas
)
if missing_product_areas:
print(
"Canonical product areas have no contributing module: "
+ ", ".join(missing_product_areas),
file=sys.stderr,
)
return 1
registry = PlatformRegistry()
try:
for manifest in manifests:
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Enforce Core ownership of WebUI visual foundations."""
from __future__ import annotations
import pathlib
import re
import sys
META_ROOT = pathlib.Path(__file__).resolve().parents[2]
REPOS_ROOT = META_ROOT.parent
TOKENS_PATH = REPOS_ROOT / "govoplan-core/webui/src/styles/tokens.css"
RAW_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{3,8}\b")
RAW_COLOR_FUNCTION = re.compile(r"\b(?:rgb|rgba|hsl|hsla)\((?!\s*var\()", re.IGNORECASE)
RADIUS_DECLARATION = re.compile(r"border-radius\s*:\s*([^;}]+)")
MEDIA_MAX_WIDTH = re.compile(r"@media[^\n{]*\(max-width\s*:\s*(\d+)px\)")
RESPONSIVE_BANDS = {560, 600, 680, 760, 900, 1100, 1280}
REQUIRED_TOKENS = {
"--radius-hairline",
"--radius-tight",
"--radius-xs",
"--radius-sm",
"--radius-compact",
"--radius-md",
"--radius-lg",
"--radius-xl",
"--radius-round",
"--radius-pill",
"--shadow-drawer-side",
"--shadow-drawer-bottom",
"--action-primary-bg",
"--action-primary-border",
"--action-primary-text",
"--action-danger-bg",
"--action-danger-text",
"--badge-accent-text",
"--data-category-blue",
"--data-category-green",
"--data-category-amber",
"--data-category-purple",
"--data-category-rose",
*(f"--data-series-{index}" for index in range(1, 9)),
}
def line_number(source: str, offset: int) -> int:
return source.count("\n", 0, offset) + 1
def css_files() -> list[pathlib.Path]:
files: list[pathlib.Path] = []
for repository in sorted(REPOS_ROOT.glob("govoplan-*")):
styles = repository / "webui/src"
if styles.is_dir():
files.extend(sorted(styles.rglob("*.css")))
return files
def display_path(path: pathlib.Path) -> str:
return str(path.relative_to(REPOS_ROOT))
def main() -> int:
errors: list[str] = []
tokens = TOKENS_PATH.read_text(encoding="utf-8")
for token in sorted(REQUIRED_TOKENS):
if f"{token}:" not in tokens:
errors.append(f"{display_path(TOKENS_PATH)}: missing required foundation token {token}")
files = css_files()
for path in files:
source = path.read_text(encoding="utf-8")
owns_literals = path == TOKENS_PATH
if not owns_literals:
for pattern, label in (
(RAW_HEX_COLOR, "raw color"),
(RAW_COLOR_FUNCTION, "raw color function"),
):
for match in pattern.finditer(source):
errors.append(
f"{display_path(path)}:{line_number(source, match.start())}: "
f"{label} must use a Core theme token"
)
for match in RADIUS_DECLARATION.finditer(source):
value = match.group(1).strip()
if "var(" not in value and value not in {"0", "inherit", "initial", "unset"}:
errors.append(
f"{display_path(path)}:{line_number(source, match.start())}: "
f"border radius {value!r} must use a Core radius token"
)
for match in MEDIA_MAX_WIDTH.finditer(source):
width = int(match.group(1))
if width not in RESPONSIVE_BANDS:
errors.append(
f"{display_path(path)}:{line_number(source, match.start())}: "
f"{width}px is not a shared responsive band; use one of "
f"{', '.join(f'{value}px' for value in sorted(RESPONSIVE_BANDS))}"
)
if errors:
print("\n".join(errors))
return 1
print(
"Shared WebUI foundation contract passed for "
f"{len(files)} stylesheets and {len(RESPONSIVE_BANDS)} responsive bands."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -34,7 +34,6 @@ 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
@@ -64,4 +63,3 @@ 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