diff --git a/README.md b/README.md index 1cb155b..b4e3bcd 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ **Repository type:** module (domain). +The service-directory route, state, blocker, and accessibility mapping is +recorded in +[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md). + Portal owns service discovery, presentation, and channel entry. Its `portal.service_directory` capability projects provider-owned, versioned service definitions into available, explainably unavailable, or undiscoverable diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..c2be16b --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,27 @@ +# Portal Interface Pattern Migration + +Portal is a public-service-style directory for authenticated institutional +users. Services owns definitions, Policy/provider capabilities own availability, +and Cases, Forms Runtime, or Workflow Engine owns the launch effect. Portal +does not import those modules or infer their state. + +| Surface | Task and archetype | Consequence and state contract | +| --- | --- | --- | +| `/portal` toolbar | Find and filter services | Search and the unavailable-service toggle affect only the directory result. Counts are announced as the result changes. | +| Service directory | Public service/entry catalogue | Loading, empty, failed, available, unavailable, and capability-unknown states remain distinct. Only discoverable service metadata is rendered. | +| Availability blocker | Explained disabled action | Open remains in its stable action position. The blocker states why launch is unavailable, what must happen, the responsible actor, and the destination. | +| Open service | Consequential handoff | Portal re-evaluates the exact service revision with a replay-safe request, then hands off to the provider-owned route. It never reports success without a destination. | + +The directory uses Core buttons, toggle, status, loading, alerts, scrolling, +blocker explanation, guarded navigation, and documentation help. Availability +is expressed in text as well as color. The responsive toolbar stacks before the +service list and every action remains keyboard-operable. Optional owner modules +remain capability-based and an unavailable provider does not become a broken +link. + +Verification: + +- `npm run test:interface-pattern` +- Portal service-directory and manifest tests +- the Core TypeScript graph, structural localization audit, theme check, module + permutations, and full-product bundle budget diff --git a/src/govoplan_portal/backend/manifest.py b/src/govoplan_portal/backend/manifest.py index 72777b3..aae9f56 100644 --- a/src/govoplan_portal/backend/manifest.py +++ b/src/govoplan_portal/backend/manifest.py @@ -182,6 +182,11 @@ manifest = ModuleManifest( href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md", kind="repository", ), + DocumentationLink( + label="Portal interface pattern audit", + href="govoplan-portal/docs/INTERFACE_PATTERN_MIGRATION.md", + kind="repository", + ), ), ), ), diff --git a/webui/package.json b/webui/package.json index 96861e1..edcd8bc 100644 --- a/webui/package.json +++ b/webui/package.json @@ -13,6 +13,9 @@ }, "./styles/portal.css": "./src/styles/portal.css" }, + "scripts": { + "test:interface-pattern": "node scripts/test-interface-pattern.mjs" + }, "peerDependencies": { "@govoplan/core-webui": "^0.1.14", "lucide-react": "^1.23.0", diff --git a/webui/scripts/test-interface-pattern.mjs b/webui/scripts/test-interface-pattern.mjs new file mode 100644 index 0000000..79c9c00 --- /dev/null +++ b/webui/scripts/test-interface-pattern.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const page = fs.readFileSync("src/features/portal/PortalPage.tsx", "utf8"); +const styles = fs.readFileSync("src/styles/portal.css", "utf8"); + +assert.ok(page.includes("DocumentationHelpLink"), "Portal exposes configured-system help"); +assert.ok(page.includes("ActionBlockerHint"), "Unavailable launches expose the shared structured blocker"); +assert.ok(page.includes("disabledReason={entry.state !== \"available\" ? blocker.summary : undefined}"), "The launch action remains keyboard-explainable"); +assert.ok(page.includes("PageScrollViewport"), "Portal owns bounded directory scrolling"); +assert.ok(page.includes('aria-live="polite"'), "Changing result counts are announced"); +assert.ok(page.includes("useGuardedNavigate"), "Internal launch handoffs respect unsaved-work navigation"); +assert.ok(!page.includes("window.alert("), "Portal must not use browser alerts"); +assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Portal uses semantic interactive elements"); +assert.ok(styles.includes("@media (max-width: 720px)"), "Portal retains a narrow-viewport toolbar layout"); + +console.log("Portal interface pattern contract passed."); diff --git a/webui/src/features/portal/PortalPage.tsx b/webui/src/features/portal/PortalPage.tsx index 3b2ea50..f1c6678 100644 --- a/webui/src/features/portal/PortalPage.tsx +++ b/webui/src/features/portal/PortalPage.tsx @@ -7,8 +7,10 @@ import { type FormEvent } from "react"; import { + ActionBlockerHint, DismissibleAlert, Button, + DocumentationHelpLink, LoadingIndicator, PageScrollViewport, StatusBadge, @@ -119,8 +121,12 @@ export default function PortalPage({ settings }: PlatformRouteContext) { aria-label="Search services" placeholder="Search services" /> - + + void; }) { const reasons = userFacingReasons(entry.reason_codes); + const blocker = serviceBlocker(entry.reason_codes, reasons); return (
@@ -196,20 +203,60 @@ function ServiceEntry({ {reasons.map((reason) =>
  • {reason}
  • )} } + {entry.entry_binding && entry.state !== "available" && + + }
    - {entry.entry_binding && entry.state === "available" ? - : - entry.entry_binding && - {humanize(entry.entry_binding.kind)} + No launch destination }
    ); } +function serviceBlocker(codes: string[], reasons: string[]) { + const reasonCode = codes.find((code) => !code.startsWith("service.explanation:")) ?? ""; + if (reasonCode === "service.publication.suspended") { + return { + summary: reasons[0] ?? "This service is temporarily suspended.", + requiredAction: "Resume the published service revision.", + actor: "Service owner", + target: "Service administration" + }; + } + if (reasonCode.includes("required_module.missing") || reasonCode.includes("required_capability.missing")) { + return { + summary: reasons[0] ?? "A required system component is unavailable.", + requiredAction: "Install, enable, configure, or restore the required component.", + actor: "System or module administrator", + target: "Module administration" + }; + } + if (reasonCode.includes("evaluator_failed") || reasonCode.includes("requirement.unknown")) { + return { + summary: reasons[0] ?? "Availability could not be confirmed.", + requiredAction: "Restore the availability evaluator and check the service again.", + actor: "System operator", + target: "Operations and service diagnostics" + }; + } + return { + summary: reasons[0] ?? "This service is currently unavailable.", + requiredAction: "Review and fulfil the service availability requirements.", + actor: "Service owner or responsible authority", + target: "Service details" + }; +} + function userFacingReasons(codes: string[]): string[] { const values = codes. filter((code) => !code.startsWith("service.explanation:")). diff --git a/webui/src/styles/portal.css b/webui/src/styles/portal.css index dd1a96a..92c111c 100644 --- a/webui/src/styles/portal.css +++ b/webui/src/styles/portal.css @@ -113,6 +113,10 @@ font-size: 0.86rem; } +.portal-service-entry .action-blocker-hint { + margin-top: 12px; +} + .portal-service-actions { display: flex; align-items: center;