Adopt the shared interface pattern language

This commit is contained in:
2026-08-04 08:21:50 +02:00
parent 49cbe258e0
commit 075b6df175
7 changed files with 112 additions and 5 deletions
+4
View File
@@ -4,6 +4,10 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
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 owns service discovery, presentation, and channel entry. Its
`portal.service_directory` capability projects provider-owned, versioned `portal.service_directory` capability projects provider-owned, versioned
service definitions into available, explainably unavailable, or undiscoverable service definitions into available, explainably unavailable, or undiscoverable
+27
View File
@@ -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
+5
View File
@@ -182,6 +182,11 @@ manifest = ModuleManifest(
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md", href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
kind="repository", kind="repository",
), ),
DocumentationLink(
label="Portal interface pattern audit",
href="govoplan-portal/docs/INTERFACE_PATTERN_MIGRATION.md",
kind="repository",
),
), ),
), ),
), ),
+3
View File
@@ -13,6 +13,9 @@
}, },
"./styles/portal.css": "./src/styles/portal.css" "./styles/portal.css": "./src/styles/portal.css"
}, },
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.14", "@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
+17
View File
@@ -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.");
+52 -5
View File
@@ -7,8 +7,10 @@ import {
type FormEvent type FormEvent
} from "react"; } from "react";
import { import {
ActionBlockerHint,
DismissibleAlert, DismissibleAlert,
Button, Button,
DocumentationHelpLink,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatusBadge, StatusBadge,
@@ -119,8 +121,12 @@ export default function PortalPage({ settings }: PlatformRouteContext) {
aria-label="Search services" aria-label="Search services"
placeholder="Search services" placeholder="Search services"
/> />
<button type="submit" className="btn btn-primary">Search</button> <Button type="submit" variant="primary">Search</Button>
</form> </form>
<DocumentationHelpLink
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
label="Open service directory documentation"
/>
<ToggleSwitch <ToggleSwitch
label="Show unavailable services" label="Show unavailable services"
checked={includeUnavailable} checked={includeUnavailable}
@@ -171,6 +177,7 @@ function ServiceEntry({
onLaunch: () => void; onLaunch: () => void;
}) { }) {
const reasons = userFacingReasons(entry.reason_codes); const reasons = userFacingReasons(entry.reason_codes);
const blocker = serviceBlocker(entry.reason_codes, reasons);
return ( return (
<article className={`portal-service-entry is-${entry.state}`}> <article className={`portal-service-entry is-${entry.state}`}>
<div className="portal-service-heading"> <div className="portal-service-heading">
@@ -196,20 +203,60 @@ function ServiceEntry({
{reasons.map((reason) => <li key={reason}>{reason}</li>)} {reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul> </ul>
} }
{entry.entry_binding && entry.state !== "available" &&
<ActionBlockerHint reason={blocker} tone="warning" />
}
<div className="portal-service-actions"> <div className="portal-service-actions">
{entry.entry_binding && entry.state === "available" ? {entry.entry_binding ?
<Button variant="primary" disabled={launching} onClick={onLaunch}> <Button
variant="primary"
disabled={launching || entry.state !== "available"}
disabledReason={entry.state !== "available" ? blocker.summary : undefined}
onClick={onLaunch}>
{launching ? "Starting" : "Open"} {launching ? "Starting" : "Open"}
<ArrowUpRight size={15} aria-hidden="true" /> <ArrowUpRight size={15} aria-hidden="true" />
</Button> : </Button> :
entry.entry_binding && <span className="portal-entry-kind">No launch destination</span>
<span className="portal-entry-kind">{humanize(entry.entry_binding.kind)}</span>
} }
</div> </div>
</article> </article>
); );
} }
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[] { function userFacingReasons(codes: string[]): string[] {
const values = codes. const values = codes.
filter((code) => !code.startsWith("service.explanation:")). filter((code) => !code.startsWith("service.explanation:")).
+4
View File
@@ -113,6 +113,10 @@
font-size: 0.86rem; font-size: 0.86rem;
} }
.portal-service-entry .action-blocker-hint {
margin-top: 12px;
}
.portal-service-actions { .portal-service-actions {
display: flex; display: flex;
align-items: center; align-items: center;