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
+3
View File
@@ -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",
+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
} 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"
/>
<button type="submit" className="btn btn-primary">Search</button>
<Button type="submit" variant="primary">Search</Button>
</form>
<DocumentationHelpLink
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
label="Open service directory documentation"
/>
<ToggleSwitch
label="Show unavailable services"
checked={includeUnavailable}
@@ -171,6 +177,7 @@ function ServiceEntry({
onLaunch: () => void;
}) {
const reasons = userFacingReasons(entry.reason_codes);
const blocker = serviceBlocker(entry.reason_codes, reasons);
return (
<article className={`portal-service-entry is-${entry.state}`}>
<div className="portal-service-heading">
@@ -196,20 +203,60 @@ function ServiceEntry({
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
}
{entry.entry_binding && entry.state !== "available" &&
<ActionBlockerHint reason={blocker} tone="warning" />
}
<div className="portal-service-actions">
{entry.entry_binding && entry.state === "available" ?
<Button variant="primary" disabled={launching} onClick={onLaunch}>
{entry.entry_binding ?
<Button
variant="primary"
disabled={launching || entry.state !== "available"}
disabledReason={entry.state !== "available" ? blocker.summary : undefined}
onClick={onLaunch}>
{launching ? "Starting" : "Open"}
<ArrowUpRight size={15} aria-hidden="true" />
</Button> :
entry.entry_binding &&
<span className="portal-entry-kind">{humanize(entry.entry_binding.kind)}</span>
<span className="portal-entry-kind">No launch destination</span>
}
</div>
</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[] {
const values = codes.
filter((code) => !code.startsWith("service.explanation:")).
+4
View File
@@ -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;