diff --git a/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md b/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md index 79a79bf..e481161 100644 --- a/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md +++ b/docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md @@ -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 diff --git a/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md b/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md index 004bf65..e0ad02b 100644 --- a/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md +++ b/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md @@ -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, diff --git a/packages/product/service-to-decision/README.md b/packages/product/service-to-decision/README.md index 536d6ba..71653e8 100644 --- a/packages/product/service-to-decision/README.md +++ b/packages/product/service-to-decision/README.md @@ -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: diff --git a/packages/product/service-to-decision/package.json b/packages/product/service-to-decision/package.json index 44ba308..4b1f7c1 100644 --- a/packages/product/service-to-decision/package.json +++ b/packages/product/service-to-decision/package.json @@ -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", diff --git a/tests/fixtures/resident_parking_permit_journey.json b/tests/fixtures/resident_parking_permit_journey.json new file mode 100644 index 0000000..628e6fc --- /dev/null +++ b/tests/fixtures/resident_parking_permit_journey.json @@ -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." + ] + } +} diff --git a/tests/test_institutional_governance_journey.py b/tests/test_institutional_governance_journey.py index e0ab903..41cabbb 100644 --- a/tests/test_institutional_governance_journey.py +++ b/tests/test_institutional_governance_journey.py @@ -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]) diff --git a/tests/test_institutional_service_journey.py b/tests/test_institutional_service_journey.py index 3e99d81..b538703 100644 --- a/tests/test_institutional_service_journey.py +++ b/tests/test_institutional_service_journey.py @@ -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?")) diff --git a/tools/checks/check-focused.sh b/tools/checks/check-focused.sh index 3eb6f99..01b51cb 100644 --- a/tools/checks/check-focused.sh +++ b/tools/checks/check-focused.sh @@ -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 diff --git a/tools/checks/check-manifest-shapes.py b/tools/checks/check-manifest-shapes.py index 70ec3e9..c71ee4c 100644 --- a/tools/checks/check-manifest-shapes.py +++ b/tools/checks/check-manifest-shapes.py @@ -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: diff --git a/tools/checks/check-shared-webui-foundations.py b/tools/checks/check-shared-webui-foundations.py new file mode 100644 index 0000000..740479d --- /dev/null +++ b/tools/checks/check-shared-webui-foundations.py @@ -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()) diff --git a/tools/checks/shared-webui-dialog-width-exceptions.txt b/tools/checks/shared-webui-dialog-width-exceptions.txt index 28ef0e7..b736edb 100644 --- a/tools/checks/shared-webui-dialog-width-exceptions.txt +++ b/tools/checks/shared-webui-dialog-width-exceptions.txt @@ -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 -