diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md
index d90d979..aa9f7ca 100644
--- a/docs/MODULE_ARCHITECTURE.md
+++ b/docs/MODULE_ARCHITECTURE.md
@@ -1030,6 +1030,32 @@ Any future exception is extraction debt and must be temporary, documented in the
script with a reason, and removed when a capability/API/event contract replaces
it.
+## Product Surface Contributions
+
+`FrontendModule.product_surfaces` is the versioned product-composition contract
+for stable identities that may have one or more technical owners. A contribution
+declares contract version 1, a product identity, common label/icon/description,
+stable entry path, owner route and View surfaces, supported task/reader/admin/
+operator presentations, authorization requirements, capabilities, search
+sources, help contexts, documentation topics, migration aliases, and standard
+unavailable/degraded explanations.
+
+Core validates every reference against the owning manifest. Contributors that
+share an identity must agree on its common product metadata and entry path;
+entry and alias paths cannot belong to another product identity. The WebUI
+composes valid owners by product id, filters them through authorization and the
+effective View, and resolves the stable entry or migration alias to the first
+available owner route. It emits `govoplan:product-surface-route-resolved` before
+the redirect so migration telemetry can observe alias use without making the
+technical module part of the ordinary label.
+
+Use `ProductAvailabilityState` for unavailable and degraded outcomes. The
+ordinary state explains the attempted outcome, consequence, recovery path and
+responsible role. Exact module, capability, provider and correlation values may
+be supplied as a collapsed technical detail; they are not the primary error.
+The state is presentation only and never grants authority or changes provider
+health.
+
## Boundary Decision Register
These durable decisions close older exploratory core issues. Implementation
diff --git a/pyproject.toml b/pyproject.toml
index 7dcc680..c00f520 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
-version = "0.1.39"
+version = "0.1.40"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py
index 5583ee4..954195d 100644
--- a/src/govoplan_core/core/modules.py
+++ b/src/govoplan_core/core/modules.py
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
+SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
PermissionLevel = Literal["system", "tenant"]
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
@@ -114,6 +115,55 @@ class ProductAreaContribution:
order: int = 100
+ProductSurfacePresentation = Literal["task", "reader", "admin", "operator"]
+ProductAvailabilityReason = Literal[
+ "authorization",
+ "policy",
+ "configuration",
+ "disabled",
+ "capability",
+ "offline",
+ "provider_degraded",
+]
+
+
+@dataclass(frozen=True, slots=True)
+class ProductAvailabilityExplanation:
+ """Explain a product outcome without making package topology user-facing."""
+
+ reason: ProductAvailabilityReason
+ title: str
+ description: str
+ resolution: str
+ responsible_role: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class ProductSurfaceContribution:
+ """Bind an owner route to a stable, cross-module product identity."""
+
+ id: str
+ module_id: str
+ label: str
+ icon: str
+ entry_path: str
+ route_path: str
+ surface_ids: tuple[str, ...]
+ unavailable: ProductAvailabilityExplanation
+ description: str | None = None
+ degraded: ProductAvailabilityExplanation | None = None
+ presentations: tuple[ProductSurfacePresentation, ...] = ("task",)
+ capability_ids: tuple[str, ...] = ()
+ search_source_ids: tuple[str, ...] = ()
+ help_context_ids: tuple[str, ...] = ()
+ documentation_topic_ids: tuple[str, ...] = ()
+ required_all: tuple[str, ...] = ()
+ required_any: tuple[str, ...] = ()
+ aliases: tuple[str, ...] = ()
+ order: int = 100
+ contract_version: str = SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION
+
+
@dataclass(frozen=True, slots=True)
class QuickAccessTool:
"""Declare a versioned, bounded module-owned Quick Access tool."""
@@ -153,6 +203,7 @@ class FrontendModule:
settings_routes: tuple[FrontendRoute, ...] = ()
view_surfaces: tuple[ViewSurface, ...] = ()
product_areas: tuple[ProductAreaContribution, ...] = ()
+ product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
quick_access_tools: tuple[QuickAccessTool, ...] = ()
diff --git a/src/govoplan_core/core/platform_interfaces.py b/src/govoplan_core/core/platform_interfaces.py
index 90c53d6..8641e82 100644
--- a/src/govoplan_core/core/platform_interfaces.py
+++ b/src/govoplan_core/core/platform_interfaces.py
@@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[
"navigation",
"permission",
"product_area",
+ "product_surface",
"provided_interface",
"public_route",
"search_provider",
@@ -237,6 +238,41 @@ def manifest_interface_declarations(
},
)
)
+ for surface in frontend.product_surfaces:
+ declarations.append(
+ PlatformInterfaceDeclaration(
+ id=f"{manifest.id}.{surface.id}",
+ module_id=manifest.id,
+ kind="product_surface",
+ label=surface.label,
+ path=surface.route_path,
+ required_all=surface.required_all,
+ required_any=surface.required_any,
+ metadata={
+ "contract_version": surface.contract_version,
+ "product_surface_id": surface.id,
+ "description": surface.description,
+ "icon": surface.icon,
+ "entry_path": surface.entry_path,
+ "surface_ids": list(surface.surface_ids),
+ "presentations": list(surface.presentations),
+ "capability_ids": list(surface.capability_ids),
+ "search_source_ids": list(surface.search_source_ids),
+ "help_context_ids": list(surface.help_context_ids),
+ "documentation_topic_ids": list(
+ surface.documentation_topic_ids
+ ),
+ "aliases": list(surface.aliases),
+ "order": surface.order,
+ "unavailable_reason": surface.unavailable.reason,
+ "degraded_reason": (
+ surface.degraded.reason
+ if surface.degraded is not None
+ else None
+ ),
+ },
+ )
+ )
for tool in frontend.quick_access_tools:
declarations.append(
PlatformInterfaceDeclaration(
diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py
index 314b69a..67b5856 100644
--- a/src/govoplan_core/core/registry.py
+++ b/src/govoplan_core/core/registry.py
@@ -16,13 +16,16 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
+ ProductAvailabilityExplanation,
ProductAreaContribution,
+ ProductSurfaceContribution,
PublicFrontendRoute,
QuickAccessTool,
ResourceAclProvider,
RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION,
+ SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION,
TenantSummaryBatchProvider,
TenantSummaryProvider,
user_workflow_scope_condition_issues,
@@ -90,6 +93,9 @@ _WILDCARD_RE = re.compile(
)
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
+_PRODUCT_SURFACE_ID_RE = re.compile(
+ r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$"
+)
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
)
@@ -974,7 +980,19 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
area_definitions: dict[str, tuple[str, str]] = {}
+ surface_definitions: dict[str, tuple[str, str, str, str | None]] = {}
+ product_paths: dict[str, str] = {}
tool_owners: dict[str, str] = {}
+ concrete_paths = {
+ route.path: manifest.id
+ for manifest in manifests
+ if manifest.frontend is not None
+ for route in (
+ *manifest.frontend.routes,
+ *manifest.frontend.settings_routes,
+ *manifest.frontend.public_routes,
+ )
+ }
for manifest in manifests:
frontend = manifest.frontend
if frontend is None:
@@ -987,6 +1005,33 @@ def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> Non
f"Product area {area.id!r} has conflicting labels or icons"
)
area_definitions[area.id] = definition
+ for surface in frontend.product_surfaces:
+ definition = (
+ surface.label,
+ surface.icon,
+ surface.entry_path,
+ surface.description,
+ )
+ previous = surface_definitions.get(surface.id)
+ if previous is not None and previous != definition:
+ raise RegistryError(
+ f"Product surface {surface.id!r} has conflicting product identity metadata"
+ )
+ surface_definitions[surface.id] = definition
+ for path in (surface.entry_path, *surface.aliases):
+ concrete_owner = concrete_paths.get(path)
+ if concrete_owner is not None:
+ raise RegistryError(
+ f"Product path {path!r} collides with a concrete route "
+ f"owned by module {concrete_owner!r}"
+ )
+ previous_id = product_paths.get(path)
+ if previous_id is not None and previous_id != surface.id:
+ raise RegistryError(
+ f"Product path {path!r} is shared by product surfaces "
+ f"{previous_id!r} and {surface.id!r}"
+ )
+ product_paths[path] = surface.id
for tool in frontend.quick_access_tools:
previous_owner = tool_owners.get(tool.id)
if previous_owner is not None:
@@ -1474,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
f"in module {manifest.id!r}"
)
seen_area_memberships.add(membership)
+ seen_product_surfaces: set[str] = set()
+ for surface in frontend.product_surfaces:
+ _validate_product_surface(manifest, surface, known_surface_ids)
+ if surface.id in seen_product_surfaces:
+ raise RegistryError(
+ f"Duplicate product surface {surface.id!r} in module {manifest.id!r}"
+ )
+ seen_product_surfaces.add(surface.id)
seen_tools: set[str] = set()
for tool in frontend.quick_access_tools:
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
@@ -1512,6 +1565,151 @@ def _validate_product_area(
)
+def _validate_product_surface(
+ manifest: ModuleManifest,
+ surface: ProductSurfaceContribution,
+ known_surface_ids: set[str],
+) -> None:
+ module_id = manifest.id
+ frontend = manifest.frontend
+ assert frontend is not None
+ if surface.module_id != module_id:
+ raise RegistryError(
+ f"Product surface {surface.id!r} belongs to {surface.module_id!r}, "
+ f"not module {module_id!r}"
+ )
+ if not _PRODUCT_SURFACE_ID_RE.fullmatch(surface.id):
+ raise RegistryError(f"Invalid product surface id: {surface.id!r}")
+ if surface.contract_version != SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION:
+ raise RegistryError(
+ f"Product surface {surface.id!r} uses unsupported contract version "
+ f"{surface.contract_version!r}"
+ )
+ if not surface.label.strip() or not surface.icon.strip():
+ raise RegistryError(
+ f"Product surface {surface.id!r} needs a label and icon"
+ )
+ for label, path in (
+ ("entry", surface.entry_path),
+ ("owner", surface.route_path),
+ *(("alias", alias) for alias in surface.aliases),
+ ):
+ if not path.startswith("/") or "?" in path or "#" in path:
+ raise RegistryError(
+ f"Product surface {surface.id!r} has an invalid {label} path {path!r}"
+ )
+ if (
+ surface.entry_path == surface.route_path
+ or surface.entry_path in surface.aliases
+ or surface.route_path in surface.aliases
+ ):
+ raise RegistryError(
+ f"Product surface {surface.id!r} must keep its stable entry distinct from owner and alias paths"
+ )
+ if len(set(surface.aliases)) != len(surface.aliases):
+ raise RegistryError(
+ f"Product surface {surface.id!r} contains duplicate aliases"
+ )
+ route_paths = {route.path for route in (*frontend.routes, *frontend.settings_routes)}
+ if surface.route_path not in route_paths:
+ raise RegistryError(
+ f"Product surface {surface.id!r} references unknown owner route "
+ f"{surface.route_path!r}"
+ )
+ if not surface.surface_ids:
+ raise RegistryError(
+ f"Product surface {surface.id!r} has no owner surfaces"
+ )
+ unknown_surfaces = set(surface.surface_ids) - known_surface_ids
+ if unknown_surfaces:
+ raise RegistryError(
+ f"Product surface {surface.id!r} references unknown surfaces: "
+ + ", ".join(sorted(unknown_surfaces))
+ )
+ allowed_presentations = {"task", "reader", "admin", "operator"}
+ if (
+ not surface.presentations
+ or len(set(surface.presentations)) != len(surface.presentations)
+ or set(surface.presentations) - allowed_presentations
+ ):
+ raise RegistryError(
+ f"Product surface {surface.id!r} has invalid presentations"
+ )
+ declared_capabilities = {
+ *manifest.required_capabilities,
+ *manifest.optional_capabilities,
+ *manifest.capability_factories,
+ *(provider.name for provider in manifest.provides_interfaces),
+ *(requirement.name for requirement in manifest.requires_interfaces),
+ }
+ unknown_capabilities = set(surface.capability_ids) - declared_capabilities
+ if unknown_capabilities:
+ raise RegistryError(
+ f"Product surface {surface.id!r} references undeclared capabilities: "
+ + ", ".join(sorted(unknown_capabilities))
+ )
+ search_source_ids = {source.id for source in manifest.search_sources}
+ unknown_search_sources = set(surface.search_source_ids) - search_source_ids
+ if unknown_search_sources:
+ raise RegistryError(
+ f"Product surface {surface.id!r} references unknown search sources: "
+ + ", ".join(sorted(unknown_search_sources))
+ )
+ topics = {topic.id: topic for topic in manifest.documentation}
+ unknown_topics = set(surface.documentation_topic_ids) - set(topics)
+ if unknown_topics:
+ raise RegistryError(
+ f"Product surface {surface.id!r} references unknown documentation topics: "
+ + ", ".join(sorted(unknown_topics))
+ )
+ documented_help_contexts: set[str] = set()
+ for topic in manifest.documentation:
+ contexts = topic.metadata.get("help_contexts", ())
+ if isinstance(contexts, (list, tuple, set, frozenset)):
+ documented_help_contexts.update(
+ context for context in contexts if isinstance(context, str)
+ )
+ unknown_help = set(surface.help_context_ids) - documented_help_contexts
+ if unknown_help:
+ raise RegistryError(
+ f"Product surface {surface.id!r} references undocumented help contexts: "
+ + ", ".join(sorted(unknown_help))
+ )
+ _validate_product_availability_explanation(surface.id, surface.unavailable)
+ if surface.degraded is not None:
+ _validate_product_availability_explanation(surface.id, surface.degraded)
+
+
+def _validate_product_availability_explanation(
+ surface_id: str,
+ explanation: ProductAvailabilityExplanation,
+) -> None:
+ allowed_reasons = {
+ "authorization",
+ "policy",
+ "configuration",
+ "disabled",
+ "capability",
+ "offline",
+ "provider_degraded",
+ }
+ if explanation.reason not in allowed_reasons:
+ raise RegistryError(
+ f"Product surface {surface_id!r} has an invalid availability reason"
+ )
+ if any(
+ not value.strip()
+ for value in (
+ explanation.title,
+ explanation.description,
+ explanation.resolution,
+ )
+ ):
+ raise RegistryError(
+ f"Product surface {surface_id!r} has an incomplete availability explanation"
+ )
+
+
def _validate_quick_access_tool(
module_id: str,
tool: QuickAccessTool,
diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py
index 7a7e009..965557f 100644
--- a/src/govoplan_core/server/platform.py
+++ b/src/govoplan_core/server/platform.py
@@ -22,7 +22,9 @@ from govoplan_core.core.modules import (
FrontendRoute,
ModuleManifest,
NavItem,
+ ProductAvailabilityExplanation,
ProductAreaContribution,
+ ProductSurfaceContribution,
PublicFrontendRoute,
QuickAccessTool,
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
@@ -254,6 +256,47 @@ def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]:
}
+def _product_availability_payload(
+ explanation: ProductAvailabilityExplanation,
+) -> dict[str, object]:
+ return {
+ "reason": explanation.reason,
+ "title": explanation.title,
+ "description": explanation.description,
+ "resolution": explanation.resolution,
+ "responsible_role": explanation.responsible_role,
+ }
+
+
+def _product_surface_payload(surface: ProductSurfaceContribution) -> dict[str, object]:
+ return {
+ "contract_version": surface.contract_version,
+ "id": surface.id,
+ "module_id": surface.module_id,
+ "label": surface.label,
+ "description": surface.description,
+ "icon": surface.icon,
+ "entry_path": surface.entry_path,
+ "route_path": surface.route_path,
+ "surface_ids": list(surface.surface_ids),
+ "presentations": list(surface.presentations),
+ "capability_ids": list(surface.capability_ids),
+ "search_source_ids": list(surface.search_source_ids),
+ "help_context_ids": list(surface.help_context_ids),
+ "documentation_topic_ids": list(surface.documentation_topic_ids),
+ "required_all": list(surface.required_all),
+ "required_any": list(surface.required_any),
+ "aliases": list(surface.aliases),
+ "order": surface.order,
+ "unavailable": _product_availability_payload(surface.unavailable),
+ "degraded": (
+ _product_availability_payload(surface.degraded)
+ if surface.degraded is not None
+ else None
+ ),
+ }
+
+
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
return {
"id": tool.id,
@@ -372,6 +415,10 @@ def _frontend_payload(
"product_areas": [
_product_area_payload(area) for area in frontend.product_areas
],
+ "product_surfaces": [
+ _product_surface_payload(surface)
+ for surface in frontend.product_surfaces
+ ],
"quick_access_tools": [
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
],
diff --git a/tests/test_presentation_contract.py b/tests/test_presentation_contract.py
index 903685b..420538d 100644
--- a/tests/test_presentation_contract.py
+++ b/tests/test_presentation_contract.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
+from dataclasses import replace
from fastapi import FastAPI
from fastapi.testclient import TestClient
@@ -10,7 +11,9 @@ from govoplan_core.core.modules import (
FrontendModule,
FrontendRoute,
ModuleManifest,
+ ProductAvailabilityExplanation,
ProductAreaContribution,
+ ProductSurfaceContribution,
QuickAccessTool,
)
from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -49,6 +52,26 @@ def presentation_manifest() -> ModuleManifest:
surface_ids=("example.route.main",),
),
),
+ product_surfaces=(
+ ProductSurfaceContribution(
+ id="work.examples",
+ module_id="example",
+ label="Examples",
+ description="Review and update governed examples.",
+ icon="list-checks",
+ entry_path="/work/examples",
+ route_path="/example",
+ surface_ids=("example.route.main",),
+ presentations=("task", "reader"),
+ unavailable=ProductAvailabilityExplanation(
+ reason="authorization",
+ title="Examples are unavailable",
+ description="Your current responsibility does not include examples.",
+ resolution="Ask the responsible administrator to review your assignment.",
+ responsible_role="Access administrator",
+ ),
+ ),
+ ),
quick_access_tools=(
QuickAccessTool(
id="example.summary",
@@ -121,6 +144,16 @@ class PresentationContractTests(unittest.TestCase):
frontend = response.json()["modules"][0]["frontend"]
self.assertEqual("1", frontend["presentation_contract_version"])
self.assertEqual("work", frontend["product_areas"][0]["id"])
+ product_surface = frontend["product_surfaces"][0]
+ self.assertEqual("1", product_surface["contract_version"])
+ self.assertEqual("work.examples", product_surface["id"])
+ self.assertEqual("/work/examples", product_surface["entry_path"])
+ self.assertEqual("/example", product_surface["route_path"])
+ self.assertEqual(["task", "reader"], product_surface["presentations"])
+ self.assertEqual(
+ "authorization",
+ product_surface["unavailable"]["reason"],
+ )
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
self.assertEqual(
@@ -132,6 +165,61 @@ class PresentationContractTests(unittest.TestCase):
frontend["quick_access_tools"][0]["help_context_id"],
)
+ def test_registry_rejects_product_surface_without_owner_route(self) -> None:
+ manifest = presentation_manifest()
+ frontend = manifest.frontend
+ assert frontend is not None
+ surface = frontend.product_surfaces[0]
+ invalid = ModuleManifest(
+ id=manifest.id,
+ name=manifest.name,
+ version=manifest.version,
+ frontend=FrontendModule(
+ module_id=manifest.id,
+ routes=frontend.routes,
+ product_surfaces=(
+ ProductSurfaceContribution(
+ id=surface.id,
+ module_id=surface.module_id,
+ label=surface.label,
+ description=surface.description,
+ icon=surface.icon,
+ entry_path=surface.entry_path,
+ route_path="/missing",
+ surface_ids=surface.surface_ids,
+ unavailable=surface.unavailable,
+ ),
+ ),
+ ),
+ )
+ registry = PlatformRegistry()
+ registry.register(invalid)
+
+ with self.assertRaisesRegex(RegistryError, "unknown owner route"):
+ registry.validate()
+
+ def test_registry_rejects_product_alias_that_shadows_a_route(self) -> None:
+ manifest = presentation_manifest()
+ frontend = manifest.frontend
+ assert frontend is not None
+ surface = frontend.product_surfaces[0]
+ invalid = replace(
+ manifest,
+ frontend=replace(
+ frontend,
+ routes=(
+ *frontend.routes,
+ FrontendRoute(path="/shortcut", component="ShortcutPage"),
+ ),
+ product_surfaces=(replace(surface, aliases=("/shortcut",)),),
+ ),
+ )
+ registry = PlatformRegistry()
+ registry.register(invalid)
+
+ with self.assertRaisesRegex(RegistryError, "collides with a concrete route"):
+ registry.validate()
+
if __name__ == "__main__":
unittest.main()
diff --git a/webui/package-lock.json b/webui/package-lock.json
index df68b3a..f839c7e 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
- "version": "0.1.39",
+ "version": "0.1.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
- "version": "0.1.39",
+ "version": "0.1.40",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json
index 47278da..f70c61d 100644
--- a/webui/package-lock.release.json
+++ b/webui/package-lock.release.json
@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
- "version": "0.1.39",
+ "version": "0.1.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
- "version": "0.1.39",
+ "version": "0.1.40",
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
@@ -19,7 +19,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.23",
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
- "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.24",
+ "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.25",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.18",
@@ -948,8 +948,8 @@
}
},
"node_modules/@govoplan/mail-webui": {
- "version": "0.1.24",
- "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#48205e6e461f5f93d88380df8e23db54fdb719f7",
+ "version": "0.1.25",
+ "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#ecc1283de76f154919c786cb84354afb8f2299c1",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
diff --git a/webui/package.json b/webui/package.json
index cf40a80..eee3746 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
- "version": "0.1.39",
+ "version": "0.1.40",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -42,7 +42,7 @@
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
- "test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/layout-primitives.test.js",
+ "test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && printf 'module.exports = {};\\n' > .component-test-build/src/components/ProductAvailabilityState.css && node .component-test-build/tests/layout-primitives.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
diff --git a/webui/package.release.json b/webui/package.release.json
index 5dec2dc..c7b4d3f 100644
--- a/webui/package.release.json
+++ b/webui/package.release.json
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
- "version": "0.1.39",
+ "version": "0.1.40",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -36,7 +36,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.23",
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
- "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.24",
+ "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.25",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18",
diff --git a/webui/scripts/audit-i18n-structural.mjs b/webui/scripts/audit-i18n-structural.mjs
index b0fb2fa..1d27eec 100644
--- a/webui/scripts/audit-i18n-structural.mjs
+++ b/webui/scripts/audit-i18n-structural.mjs
@@ -15,12 +15,14 @@ const sourceRoots = fs.readdirSync(workspaceRoot, { withFileTypes: true })
.map((entry) => path.join(workspaceRoot, entry.name, "webui", "src"))
.filter((sourceRoot) => fs.existsSync(sourceRoot));
-const generatedCatalogs = sourceRoots
- .map((sourceRoot) => path.join(sourceRoot, "i18n", "generatedTranslations.ts"))
- .filter((file) => fs.existsSync(file));
+const generatedCatalogs = sourceRoots.flatMap((sourceRoot) =>
+ fs.existsSync(path.join(sourceRoot, "i18n"))
+ ? rgFiles(path.join(sourceRoot, "i18n")).filter((file) => /Translations\.ts$/.test(file))
+ : []
+);
function scanStructuralSourcePositions(roots) {
- const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !file.endsWith("/i18n/generatedTranslations.ts")));
+ const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !/\/i18n\/[^/]*Translations\.ts$/.test(file)));
const findings = [];
for (const file of files) {
const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index 0db2af2..06918ea 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -30,6 +30,7 @@ import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
+const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
compact_tables: false,
@@ -579,7 +580,7 @@ export default function App() {
)}
{resolution}
+ {responsibleRole ? ( ++ {labels.responsibleRole}: + {responsibleRole} +
+ ) : null} + {technicalEntries.length ? ( +