feat(webui): govern actions, quick access, and metrics

Refs #264, #285, #289
This commit is contained in:
2026-08-19 18:47:46 +02:00
parent 41db78c201
commit ffaab543d2
31 changed files with 753 additions and 125 deletions
+5 -3
View File
@@ -17,12 +17,12 @@ domain modules own their compositions.
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
| Semantic page archetypes and action bar | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry and composed over the responsive toolbar | Refreshable pages require leading Reload; editor persistence owns clean/dirty/saving feedback, guarded Discard and far-right Save; destructive actions occupy an explicit separated slot; read-only pages do not invent Save | `PageLayout.tsx`, `PageActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed product page, and `check-shared-webui-layouts.py` |
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 14 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
| Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences |
| Metric groups | Reusable responsive grouping around metric cards | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides | `MetricGrid.tsx`, `MetricCard.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets |
| Metric groups and drill-downs | Reusable responsive grouping around metric cards with an explicit optional detail affordance | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides; typed link or in-page drill-downs name their destination while summary-only, non-enumerable, derived, or privacy-suppressed values remain inert | `MetricGrid.tsx`, `MetricCard.tsx`, `metric-card.test.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets |
| Description lists | Semantic property and fact presentation | Stacked and inline variants, one-to-five list columns, density, term width, wrapping, and responsive collapse replace both `admin-details-grid` and `detail-list`; `DescriptionItem` preserves native `dt`/`dd` anatomy | `DescriptionList.tsx`, `layout-primitives.test.tsx`, Access and Tenancy administration, Audit, Policy, Campaign reports/imports, Docs, Settings, Ops, and Reporting |
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
@@ -48,4 +48,6 @@ chrome. Retired copies and module-local component definitions are rejected by
`check-shared-webui-primitives.py`. That check also requires standard dialog
widths to use `Dialog size` and keeps every remaining domain-specific width in
a reviewed, decrease-only exception baseline. The companion layout check now
has zero raw page-frame and zero raw workspace exceptions.
has zero raw page-frame and zero raw workspace exceptions, discovers semantic
consumers without a hand-maintained route list, requires semantic action bars
on `WorkspaceFrame` routes, and rejects ad-hoc panel-header toolbars.
+42 -19
View File
@@ -35,9 +35,13 @@ filter is editable.
## Page Action Rules
Pass one `PageActionBar` to the `PageLayout` `actions` slot. The variant makes
the page's intent inspectable and preserves the same keyboard and visual order
across modules.
Pass one `PageActionBar` to the `PageLayout` `actions` slot. Full-canvas
workspaces use the same contract through `WorkspaceActionBar`, with an explicit
`workspace`, `collection-pane`, `detail-pane`, or `editor-pane` scope. The
variant makes the surface's intent inspectable and preserves the same keyboard
and visual order across modules. `ActionToolbar` remains the lower-level
component for section-local controls; it is not a substitute for a semantic
page or pane action bar.
| Page kind | Leading group | Trailing group |
| --- | --- | --- |
@@ -55,14 +59,24 @@ persistent side panel. Save is present only where the page owns an editable
draft; a read-only detail page must not display a disabled or inert Save merely
to fill the slot.
Editor bars always keep Discard and Save visible. They expose `clean`, `dirty`,
and `saving` status through a live status label. In the clean or saving state,
the central component disables both persistence actions and supplies the
standard explanation. A module may add a more specific validation, policy, or
permission blocker while the draft is dirty. The editor must register its
draft with `useUnsavedDraftGuard` (or a shared hook that uses the same
registration contract), so browser unload, route navigation, section changes,
and the explicit Discard path cannot silently lose work.
Editor bars always keep Discard and Save visible. Their required `state`
projection is one of `clean`, `dirty`, `invalid`, `saving`, `save-failed`, or
`conflict`, and the central component announces it through a live status label.
Clean and saving states disable both persistence actions; invalid disables Save
while retaining Discard. Failed saves and conflicts keep the draft recoverable
and allow an authorized retry after the module has shown the owning error or
conflict evidence. A module may add a more specific validation, policy, or
permission blocker. The editor must register its draft with
`useUnsavedDraftGuard` (or a shared hook that uses the same registration
contract), so browser unload, route navigation, section changes, Reload, and
the explicit Discard path cannot silently lose work.
Reload is rendered by Core from a descriptor rather than passed as arbitrary
button markup. It can project `current`, `stale`, `reloading`, or
`reload-failed`; `loading` is the shorthand for `reloading`. A failed refresh
must preserve usable loaded data, expose its stale/failure state, and leave
Reload available for recovery. Reload goes through the same unsaved-navigation
guard as route changes.
Destructive page actions use `destructiveActions`; never put a danger action in
`contextActions` or the ordinary primary group. Core renders a persistent
@@ -95,17 +109,26 @@ silently hide a normally applicable action.
- Use `MetricGrid`/`MetricCard` for summary measures, `Card` or
`ContentSection` for logical sections, and `DescriptionList` for labelled
facts.
- Add a `MetricCard.drilldown` only when the displayed measure has a useful,
authorized underlying collection or detail. Name the destination explicitly
(for example, “Review failed deliveries”) and preserve the current scope and
filters in its `href` or action. The card itself remains non-interactive so
the action is visible and keyboard-predictable. Derived, privacy-suppressed,
non-enumerable, or purely informational aggregates remain plain metrics;
when an ordinarily available drill-down is temporarily blocked, keep its
action and provide `disabledReason`.
- Preserve loaded data after a refresh failure and mark it stale; offer Reload
as the recovery action. Distinguish initial loading, empty, unavailable,
permission-blocked, conflict, success, and retry states.
## Review Evidence
Every new or changed page must have structural evidence for its page frame,
semantic archetype and slot order, refresh declaration, shared component
usage, stable disabled actions, dirty guard, destructive boundary, and
module-owned help identity. Type checks enforce conditional Reload and editor
persistence props. Product checks reject undeclared archetypes and ad-hoc
headed action fragments. Browser conformance confirms keyboard order, live
dirty-state changes, accessibility, destructive separation, narrow wrapping,
and screenshot geometry.
Every new or changed page or workspace pane must have structural evidence for
its frame, semantic archetype/scope and slot order, refresh declaration, shared
component usage, stable disabled actions, dirty guard, destructive boundary,
and module-owned help identity. Type checks enforce conditional Reload and
editor persistence props. The product check discovers all consumers, rejects
undeclared archetypes and `ActionToolbar` panel-header copies, and requires
semantic actions for every `WorkspaceFrame` route. Browser conformance confirms
keyboard order, lifecycle changes, accessibility, destructive separation,
narrow wrapping, and screenshot geometry.
+6 -1
View File
@@ -113,7 +113,7 @@ class ProductAreaContribution:
@dataclass(frozen=True, slots=True)
class QuickAccessTool:
"""Declare a compact module-owned tool for an optional Quick Access rail."""
"""Declare a versioned, bounded module-owned Quick Access tool."""
id: str
module_id: str
@@ -128,6 +128,11 @@ class QuickAccessTool:
order: int = 100
default_enabled: bool = True
modes: tuple[str, ...] = ("browse",)
contract_version: str = "1"
availability: Literal["global", "active_object"] = "global"
accepted_reference_kinds: tuple[str, ...] = ()
returned_reference_kinds: tuple[str, ...] = ()
help_context_id: str | None = None
@dataclass(frozen=True, slots=True)
+22
View File
@@ -1468,6 +1468,28 @@ def _validate_quick_access_tool(
raise RegistryError(
f"Quick Access tool {tool.id!r} must declare at least one valid mode"
)
if tool.contract_version != "1":
raise RegistryError(
f"Quick Access tool {tool.id!r} uses unsupported contract version "
f"{tool.contract_version!r}"
)
if tool.availability not in {"global", "active_object"}:
raise RegistryError(
f"Quick Access tool {tool.id!r} has invalid availability"
)
reference_kinds = (*tool.accepted_reference_kinds, *tool.returned_reference_kinds)
if any(not _QUICK_ACCESS_TOOL_ID_RE.fullmatch(kind) for kind in reference_kinds):
raise RegistryError(
f"Quick Access tool {tool.id!r} contains an invalid reference kind"
)
if tool.availability == "active_object" and not tool.accepted_reference_kinds:
raise RegistryError(
f"Contextual Quick Access tool {tool.id!r} must declare accepted reference kinds"
)
if tool.help_context_id is not None and not validate_view_surface_id(tool.help_context_id):
raise RegistryError(
f"Quick Access tool {tool.id!r} has an invalid help context id"
)
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
+5
View File
@@ -168,6 +168,11 @@ def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
"order": tool.order,
"default_enabled": tool.default_enabled,
"modes": list(tool.modes),
"contract_version": tool.contract_version,
"availability": tool.availability,
"accepted_reference_kinds": list(tool.accepted_reference_kinds),
"returned_reference_kinds": list(tool.returned_reference_kinds),
"help_context_id": tool.help_context_id,
}
+11
View File
@@ -59,6 +59,8 @@ def presentation_manifest() -> ModuleManifest:
icon="list-checks",
full_page_path="/example",
required_any=("example:item:read",),
returned_reference_kinds=("example.item",),
help_context_id="example.quick.summary",
),
),
),
@@ -120,6 +122,15 @@ class PresentationContractTests(unittest.TestCase):
self.assertEqual("1", frontend["presentation_contract_version"])
self.assertEqual("work", frontend["product_areas"][0]["id"])
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
self.assertEqual(
["example.item"],
frontend["quick_access_tools"][0]["returned_reference_kinds"],
)
self.assertEqual(
"example.quick.summary",
frontend["quick_access_tools"][0]["help_context_id"],
)
if __name__ == "__main__":
+85 -21
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
import { useLocation } from "react-router";
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
import ActionToolbar from "../src/components/ActionToolbar";
import Button from "../src/components/Button";
import Card from "../src/components/Card";
@@ -23,17 +24,20 @@ import SelectionList, { SelectionListItem, SelectionListItemContent } from "../s
import StatePanel from "../src/components/StatePanel";
import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
import {
createQuickAccessLaunchContext,
quickAccessLaunchState
} from "../src/platform/launchContext";
import type { AuthInfo } from "../src/types";
import type { ApiSettings, AuthInfo, QuickAccessToolMetadata } from "../src/types";
export default function ConformanceApp() {
const location = useLocation();
const [dialogOpen, setDialogOpen] = useState(false);
const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
return (
<main className="conformance-root" data-conformance-id="shared-ui-lab">
@@ -49,9 +53,9 @@ export default function ConformanceApp() {
<PageActionBar
variant="editor"
refreshable
dirty={editorDirty}
state={editorDirty ? "dirty" : "clean"}
label="Bearbeitungsaktionen"
reloadAction={<Button variant="ghost">Neu laden</Button>}
reloadAction={{ onReload: () => undefined, label: "Neu laden" }}
contextActions={<Button>Vorschau öffnen</Button>}
destructiveActions={<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>}
discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }}
@@ -73,11 +77,12 @@ export default function ConformanceApp() {
<section className="conformance-section" aria-labelledby="metrics-heading">
<h2 id="metrics-heading">Kennzahlen und Eigenschaften</h2>
<MetricGrid columns={4} collapseAt="standard">
<MetricCard label="Offene Aufgaben" value="18" detail="3 heute fällig" />
<MetricCard label="Offene Aufgaben" value="18" detail="3 heute fällig" drilldown={{ label: "Offene Aufgaben prüfen", onActivate: () => setMetricDrilldown("18 offene Aufgaben") }} />
<MetricCard label="Fristgerecht" value="94 %" tone="good" detail="Letzte 30 Tage" />
<MetricCard label="Klärung erforderlich" value="4" tone="warning" density="compact" />
<MetricCard label="Fehlgeschlagen" value="1" tone="danger" surface="subtle" />
</MetricGrid>
<p data-testid="metric-drilldown-result" role="status">{metricDrilldown}</p>
<ContentSection density="default" surface="subtle">
<h3>Nachvollziehbare Entscheidung</h3>
<DescriptionList columns={3} collapseAt="standard">
@@ -98,25 +103,31 @@ export default function ConformanceApp() {
primaryLabel="Vorgänge"
contentLabel="Ausgewählter Vorgang"
primary={
<SelectionList label="Vorgänge" variant="navigation">
<SelectionListItem selected>
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
</SelectionListItem>
<SelectionListItem selected={false}>
<SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" />
</SelectionListItem>
</SelectionList>
<>
<WorkspaceActionBar scope="collection-pane" variant="collection" refreshable reloadAction={{ onReload: () => undefined, label: "Vorgänge neu laden" }} contextActions={<strong>Vorgänge</strong>} createAction={<Button variant="primary">Neu</Button>} />
<SelectionList label="Vorgänge" variant="navigation">
<SelectionListItem selected>
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
</SelectionListItem>
<SelectionListItem selected={false}>
<SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" />
</SelectionListItem>
</SelectionList>
</>
}
>
<Card title="Anwohnerparkausweis" actions={<Button variant="primary">Bearbeiten</Button>}>
<p>Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.</p>
<FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel">
<FormGrid columns={2}>
<label>Zuständigkeit<input defaultValue="Bürgerdienste Mitte" /></label>
<label>Bearbeitungsfrist<input type="date" defaultValue="2026-08-28" /></label>
</FormGrid>
</FormSection>
</Card>
<>
<WorkspaceActionBar scope="detail-pane" variant="detail" contextActions={<strong>Anwohnerparkausweis</strong>} primaryActions={<Button variant="primary">Bearbeiten</Button>} destructiveActions={<Button variant="danger">Schließen</Button>} />
<Card title="Anwohnerparkausweis">
<p>Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.</p>
<FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel">
<FormGrid columns={2}>
<label>Zuständigkeit<input defaultValue="Bürgerdienste Mitte" /></label>
<label>Bearbeitungsfrist<input type="date" defaultValue="2026-08-28" /></label>
</FormGrid>
</FormSection>
</Card>
</>
</WorkspaceLayout>
</WorkspaceFrame>
</section>
@@ -154,10 +165,36 @@ export default function ConformanceApp() {
</DialogSection>
</DialogForm>
</Dialog>
{new URLSearchParams(location.search).has("quick-access") ? <QuickAccessScenario /> : null}
</main>
);
}
function QuickAccessScenario() {
const location = useLocation();
const launchContext = useMemo(() => createQuickAccessLaunchContext({
pathname: location.pathname,
search: location.search,
hash: location.hash,
historyIndex: browserHistoryIndex(),
auth: CONFORMANCE_AUTH,
activeObject: {
ownerModule: "cases",
kind: "case",
objectId: "case-1",
tenantId: "tenant-1",
label: "RPP-2026-0001 · Anwohnerparkausweis"
},
temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
}), [location.hash, location.pathname, location.search]);
return <QuickAccessRail
settings={CONFORMANCE_SETTINGS}
auth={CONFORMANCE_AUTH}
tools={CONFORMANCE_QUICK_ACCESS_TOOLS}
launchContext={launchContext}
/>;
}
function LaunchContextScenario() {
const location = useLocation();
const navigate = useGuardedNavigate();
@@ -210,6 +247,33 @@ const CONFORMANCE_AUTH = {
groups_loaded: true
} satisfies AuthInfo;
const CONFORMANCE_SETTINGS: ApiSettings = {
apiBaseUrl: "",
apiKey: "",
accessToken: ""
};
const CONFORMANCE_QUICK_ACCESS_TOOLS: QuickAccessToolMetadata[] = [{
contractVersion: "1",
id: "files.recent",
moduleId: "files",
categoryId: "files",
label: "Recent files",
description: "Select an authorized file without leaving this case.",
iconName: "files",
surfaceId: "files.route.files",
fullPagePath: "/files",
allOf: [],
anyOf: [],
order: 10,
defaultEnabled: true,
modes: ["select"],
availability: "global",
acceptedReferenceKinds: [],
returnedReferenceKinds: ["files.file-version"],
helpContextId: "files.quick_access.files"
}];
function browserHistoryIndex(): number | null {
const value = (window.history.state as { idx?: unknown } | null)?.idx;
return typeof value === "number" && Number.isInteger(value) ? value : null;
@@ -0,0 +1,24 @@
// Narrow facade used only by the conformance build. It lets the optional
// Quick Access module exercise its real rail without pulling the composed
// application's generated module catalogue into this isolated test bundle.
export { apiFetch } from "../src/api/client";
export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
export { default as IconButton } from "../src/components/IconButton";
export { default as LoadingFrame } from "../src/components/LoadingFrame";
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
export { usePlatformLanguage } from "../src/i18n/LanguageContext";
export {
dispatchQuickAccessResult,
quickAccessLaunchState
} from "../src/platform/launchContext";
export type {
ApiSettings,
QuickAccessRailProps,
QuickAccessToolsUiCapability
} from "../src/types";
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
void capabilityName;
return [];
}
+1
View File
@@ -10,6 +10,7 @@ import "../src/styles/tables.css";
import "../src/styles/badges.css";
import "../src/styles/components.css";
import "../src/styles/dialogs.css";
import "@govoplan/quick-access-webui/styles/quick-access.css";
import "./conformance.css";
const theme = new URLSearchParams(window.location.search).get("theme");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 227 KiB

@@ -62,6 +62,11 @@ test("shared components remain accessible and keyboard operable", async ({ page
await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeDisabled();
const metricDrilldown = page.getByRole("button", { name: "Offene Aufgaben prüfen" });
await metricDrilldown.focus();
await page.keyboard.press("Enter");
await expect(page.getByTestId("metric-drilldown-result")).toHaveText("18 offene Aufgaben");
const opener = page.getByTestId("open-dialog");
await opener.focus();
await page.keyboard.press("Enter");
@@ -82,6 +87,64 @@ test("full-page tool launch preserves a bounded return context", async ({ page }
await expect(page).toHaveURL(/\/?\?theme=light$/);
});
test("Quick Access preserves focus and adapts to a narrow viewport", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
categories: [{
id: "files",
label: "Files",
description: "Files beside the current task",
icon: "files",
order: 10,
enabled: true,
forced: false,
locked_by: null,
tools: [{
contract_version: "1",
id: "files.recent",
module_id: "files",
category_id: "files",
label: "Recent files",
description: "Select an authorized file without leaving this case.",
icon: "files",
surface_id: "files.route.files",
full_page_path: "/files",
required_all: [],
required_any: [],
order: 10,
default_enabled: true,
modes: ["select"],
availability: "global",
accepted_reference_kinds: [],
returned_reference_kinds: ["files.file-version"],
help_context_id: "files.quick_access.files",
enabled: true,
forced: false,
locked_by: null
}]
}],
diagnostics: []
})
});
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?theme=light&quick-access=1");
const filesTrigger = page.getByRole("button", { name: "Files" });
await expect(filesTrigger).toBeVisible();
await filesTrigger.click();
const drawer = page.getByRole("dialog", { name: "Files" });
await expect(drawer).toBeVisible();
await expect(drawer.getByRole("button", { name: "Close" })).toBeFocused();
const drawerBounds = await drawer.boundingBox();
expect(drawerBounds?.x).toBeGreaterThanOrEqual(0);
expect((drawerBounds?.x ?? 0) + (drawerBounds?.width ?? 0)).toBeLessThanOrEqual(390);
await page.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(filesTrigger).toBeFocused();
});
test("light and dark desktop geometry remains stable", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto("/?theme=light");
@@ -20,6 +20,7 @@ const pageLayout = read("src/components/PageLayout.tsx");
const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
const actionToolbar = read("src/components/ActionToolbar.tsx");
const pageActionBar = read("src/components/PageActionBar.tsx");
const workspaceActionBar = read("src/components/WorkspaceActionBar.tsx");
const contentGrid = read("src/components/ContentGrid.tsx");
const formSection = read("src/components/FormSection.tsx");
const dialogAnatomy = read("src/components/DialogAnatomy.tsx");
@@ -29,7 +30,7 @@ const authGateStyles = read("src/styles/auth-gate.css");
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
assert.match(settings, /archetype=\{editorSection \? "editor" : "workspace"\}/, "settings declare editor intent only for draft-owning sections");
assert.match(settings, /<PageActionBar[\s\S]*variant="editor"[\s\S]*dirty=\{editorDirty\}[\s\S]*discardAction=[\s\S]*saveAction=/, "settings use central dirty persistence actions");
assert.match(settings, /<PageActionBar[\s\S]*variant="editor"[\s\S]*state=\{editorSaving \? "saving" : editorDirty \? "dirty" : "clean"\}[\s\S]*discardAction=[\s\S]*saveAction=/, "settings use central lifecycle-aware persistence actions");
assert.match(retention, /<ActionBlockerHint/, "retention renders the shared actionable blocker");
assert.match(retention, /contextId: "privacy\.retention"/, "retention exposes stable admin documentation");
@@ -66,12 +67,14 @@ assert.match(pageActionBar, /variant: "detail"/, "detail pages have a semantic a
assert.match(pageActionBar, /variant: "editor"/, "editors have a semantic action-bar contract");
assert.match(pageActionBar, /variant: "overview"/, "overview pages have a semantic action-bar contract");
assert.match(pageActionBar, /variant: "workspace"/, "task workspaces have a semantic action-bar contract");
assert.match(pageActionBar, /refreshable: true;\s*reloadAction: ReactNode;/, "refreshable pages require a Reload action at type level");
assert.match(pageActionBar, /dirty: boolean;/, "editors require an explicit dirty state");
assert.match(pageActionBar, /refreshable: true;\s*reloadAction: PageReloadAction;/, "refreshable pages require a typed Reload action");
assert.match(pageActionBar, /state: PageEditorState;/, "editors require an explicit persistence lifecycle state");
assert.match(pageActionBar, /data-page-dirty-state=/, "editors announce clean, dirty, and saving states");
assert.match(pageActionBar, /<ActionSlot name="reload">/, "page action bars keep reload in a named stable slot");
assert.match(pageActionBar, /data-page-action-separation="destructive"/, "destructive page actions expose a separate semantic group");
assert.match(pageActionBar, /<ActionSlot name="discard">[\s\S]*<ActionSlot name="save">/, "editor save follows discard in the trailing group");
assert.match(workspaceActionBar, /actionScope=\{scope\}/, "workspace and pane action bars project their semantic scope centrally");
assert.match(workspaceActionBar, /WorkspaceActionBarProps = PageActionBarProps/, "workspace editor panes reuse the page persistence contract");
assert.match(contentGrid, /content-grid-collapse-\$\{collapseAt\}/, "shared grids make their collapse point explicit");
assert.match(formSection, /form-section-header/, "shared form sections own heading and action placement");
assert.match(dialogAnatomy, /dialog-actions-\$\{align\}/, "shared dialog anatomy owns footer action placement");
+58 -1
View File
@@ -1,7 +1,29 @@
import { ArrowRight } from "lucide-react";
import type { HTMLAttributes, ReactNode } from "react";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
import Button from "./Button";
export type MetricCardProps = HTMLAttributes<HTMLDivElement> & {
type MetricDrilldownBase = {
label: ReactNode;
accessibleLabel?: string;
};
export type MetricDrilldown = MetricDrilldownBase & (
| {
href: string;
onActivate?: never;
disabled?: never;
disabledReason?: never;
}
| {
href?: never;
onActivate: () => void;
disabled?: boolean;
disabledReason?: ReactNode;
}
);
export type MetricCardProps = Omit<HTMLAttributes<HTMLDivElement>, "onClick" | "role" | "tabIndex"> & {
label: ReactNode;
value: ReactNode;
tone?: "neutral" | "good" | "warning" | "danger" | "info";
@@ -9,6 +31,7 @@ export type MetricCardProps = HTMLAttributes<HTMLDivElement> & {
valueTitle?: string;
density?: "compact" | "default";
surface?: "card" | "subtle" | "flat";
drilldown?: MetricDrilldown;
};
export default function MetricCard({
@@ -19,6 +42,7 @@ export default function MetricCard({
valueTitle,
density = "default",
surface = "card",
drilldown,
className = "",
...props
}: MetricCardProps) {
@@ -26,6 +50,10 @@ export default function MetricCard({
const renderedLabel = translateReactNode(label, translateText);
const renderedValue = translateReactNode(value, translateText);
const renderedDetail = translateReactNode(detail, translateText);
const renderedDrilldownLabel = translateReactNode(drilldown?.label, translateText);
const drilldownAccessibleLabel = drilldown?.accessibleLabel
? translateText(drilldown.accessibleLabel)
: undefined;
return (
<div {...props} className={[
@@ -38,6 +66,35 @@ export default function MetricCard({
<div className="metric-label">{renderedLabel}</div>
<div className="metric-value" title={valueTitle}>{renderedValue}</div>
{renderedDetail ? <div className="metric-detail">{renderedDetail}</div> : null}
{drilldown ? (
<div className="metric-card-drilldown-slot">
{drilldown.href !== undefined ? (
<a
className="btn btn-ghost metric-card-drilldown"
href={drilldown.href}
aria-label={drilldownAccessibleLabel}
data-metric-drilldown="link"
>
{renderedDrilldownLabel}
<ArrowRight size={14} aria-hidden="true" />
</a>
) : (
<Button
type="button"
variant="ghost"
className="metric-card-drilldown"
aria-label={drilldownAccessibleLabel}
onClick={drilldown.onActivate}
disabled={drilldown.disabled}
disabledReason={drilldown.disabledReason}
data-metric-drilldown="action"
>
{renderedDrilldownLabel}
<ArrowRight size={14} aria-hidden="true" />
</Button>
)}
</div>
) : null}
</div>
);
}
+111 -20
View File
@@ -1,11 +1,24 @@
import { RefreshCw } from "lucide-react";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../types";
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
import ActionToolbar, { ToolbarGroup, type ActionToolbarDensity, type ActionToolbarSurface } from "./ActionToolbar";
import Button from "./Button";
import { useUnsavedChanges } from "./UnsavedChangesContext";
export type PageReloadAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> & PlatformInterfaceIdentityProps & {
onReload: () => void;
label?: ReactNode;
state?: PageRefreshState;
loading?: boolean;
loadingLabel?: ReactNode;
disabledReason?: ReactNode;
};
export type PageRefreshState = "current" | "stale" | "reloading" | "reload-failed";
type RefreshablePageActions = {
refreshable: true;
reloadAction: ReactNode;
reloadAction: PageReloadAction;
} | {
refreshable?: false;
reloadAction?: never;
@@ -23,6 +36,8 @@ export type PageEditorAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "ch
disabledReason?: ReactNode;
};
export type PageEditorState = "clean" | "dirty" | "invalid" | "saving" | "save-failed" | "conflict";
export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "overview";
primaryActions?: ReactNode;
@@ -41,13 +56,16 @@ export type DetailPageActionBarProps = PageActionBarCommonProps & RefreshablePag
export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "editor";
dirty: boolean;
saving?: boolean;
state: PageEditorState;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
invalidLabel?: ReactNode;
savingLabel?: ReactNode;
saveFailedLabel?: ReactNode;
conflictLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
invalidDisabledReason?: ReactNode;
discardAction: PageEditorAction;
saveAction: PageEditorAction;
primaryActions?: ReactNode;
@@ -67,6 +85,14 @@ export type PageActionBarProps =
| EditorPageActionBarProps
| WorkspacePageActionBarProps;
export type SemanticActionBarScope = "page" | "workspace" | "collection-pane" | "detail-pane" | "editor-pane";
type SemanticActionBarPresentation = {
actionScope?: SemanticActionBarScope;
density?: ActionToolbarDensity;
surface?: ActionToolbarSurface;
};
function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
return (
<span className={`page-action-slot page-action-slot-${name}`} data-page-action-slot={name}>
@@ -87,13 +113,47 @@ function DestructiveSlot({ children }: { children: ReactNode }) {
if (!children) return null;
return (
<ActionSlot name="destructive">
<span className="page-action-destructive-group" data-page-action-separation="destructive">
<span
className="page-action-destructive-group"
data-page-action-separation="destructive"
role="group"
aria-label="Destructive actions"
>
{children}
</span>
</ActionSlot>
);
}
function ReloadAction({ action }: { action: PageReloadAction }) {
const { requestNavigation } = useUnsavedChanges();
const {
onReload,
label = "i18n:govoplan-core.reload.cce71553",
state = "current",
loading = false,
loadingLabel = "Reloading…",
disabledReason,
disabled,
...buttonProps
} = action;
const reloading = loading || state === "reloading";
const reason = reloading ? "The page is already reloading." : disabledReason;
return (
<Button
{...buttonProps}
variant="ghost"
disabled={disabled || reloading}
disabledReason={reason}
aria-busy={reloading || undefined}
onClick={() => requestNavigation(onReload)}
>
<RefreshCw size={16} aria-hidden="true" />
{reloading ? loadingLabel : label}
</Button>
);
}
function EditorAction({
action,
variant,
@@ -117,23 +177,29 @@ function EditorAction({
* The named slots deliberately encode ordering. Product modules still own the
* actions, wording, permissions, blockers, and consequences placed in them.
*/
export default function PageActionBar(props: PageActionBarProps) {
const normalizedProps = props as PageActionBarProps & {
export default function PageActionBar(props: PageActionBarProps & SemanticActionBarPresentation) {
const normalizedProps = props as PageActionBarProps & SemanticActionBarPresentation & {
createAction?: ReactNode;
primaryActions?: ReactNode;
destructiveActions?: ReactNode;
dirty?: boolean;
saving?: boolean;
state?: PageEditorState;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
invalidLabel?: ReactNode;
savingLabel?: ReactNode;
saveFailedLabel?: ReactNode;
conflictLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
invalidDisabledReason?: ReactNode;
discardAction?: PageEditorAction;
saveAction?: PageEditorAction;
};
const {
variant,
actionScope = "page",
density,
surface,
refreshable = false,
reloadAction,
contextActions,
@@ -141,13 +207,16 @@ export default function PageActionBar(props: PageActionBarProps) {
createAction,
primaryActions,
destructiveActions,
dirty = false,
saving = false,
state = "clean",
dirtyLabel = "Unsaved changes",
cleanLabel = "Saved",
invalidLabel = "Review required",
savingLabel = "Saving…",
saveFailedLabel = "Save failed",
conflictLabel = "Conflict",
cleanDisabledReason = "There are no unsaved changes.",
savingDisabledReason = "Changes are already being saved.",
invalidDisabledReason = "Resolve the validation problems before saving.",
discardAction,
saveAction,
label,
@@ -172,16 +241,23 @@ export default function PageActionBar(props: PageActionBarProps) {
</>
);
} else if (variant === "editor") {
const persistenceDisabledReason = saving ? savingDisabledReason : !dirty ? cleanDisabledReason : undefined;
const discardDisabledReason = state === "saving" ? savingDisabledReason : state === "clean" ? cleanDisabledReason : undefined;
const saveDisabledReason = state === "saving"
? savingDisabledReason
: state === "clean"
? cleanDisabledReason
: state === "invalid"
? invalidDisabledReason
: undefined;
trailingActions = (
<>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
<ActionSlot name="discard">
<EditorAction action={discardAction!} variant="ghost" disabledReason={persistenceDisabledReason} />
<EditorAction action={discardAction!} variant="ghost" disabledReason={discardDisabledReason} />
</ActionSlot>
<ActionSlot name="save">
<EditorAction action={saveAction!} variant="primary" disabledReason={persistenceDisabledReason} />
<EditorAction action={saveAction!} variant="primary" disabledReason={saveDisabledReason} />
</ActionSlot>
</>
);
@@ -198,31 +274,46 @@ export default function PageActionBar(props: PageActionBarProps) {
<ActionToolbar
{...toolbarProps}
className={["page-action-bar", `page-action-bar-${variant}`, className].filter(Boolean).join(" ")}
density="compact"
density={density ?? "compact"}
justify="between"
wrap="responsive"
label={label ?? defaultLabels[variant]}
surface={surface}
interfaceId={interfaceId}
helpContextId={helpContextId}
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
data-page-action-archetype={variant}
data-action-bar-scope={actionScope}
data-page-refreshable={refreshable ? "true" : "false"}
data-page-dirty={variant === "editor" ? (dirty ? "true" : "false") : undefined}
data-page-refresh-state={refreshable
? (reloadAction?.loading ? "reloading" : reloadAction?.state ?? "current")
: undefined}
data-page-dirty={variant === "editor" ? (state === "clean" ? "false" : "true") : undefined}
>
<ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
{refreshable ? <ActionSlot name="reload">{reloadAction}</ActionSlot> : null}
{refreshable ? <ActionSlot name="reload"><ReloadAction action={reloadAction!} /></ActionSlot> : null}
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup>
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
{variant === "editor" ? (
<span
className={`page-dirty-state page-dirty-state-${saving ? "saving" : dirty ? "dirty" : "clean"}`}
data-page-dirty-state={saving ? "saving" : dirty ? "dirty" : "clean"}
className={`page-dirty-state page-dirty-state-${state}`}
data-page-dirty-state={state}
role="status"
aria-live="polite"
>
{saving ? savingLabel : dirty ? dirtyLabel : cleanLabel}
{state === "saving"
? savingLabel
: state === "invalid"
? invalidLabel
: state === "save-failed"
? saveFailedLabel
: state === "conflict"
? conflictLabel
: state === "dirty"
? dirtyLabel
: cleanLabel}
</span>
) : null}
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null}
@@ -0,0 +1,30 @@
import { createContext, useContext } from "react";
export type UnsavedNavigationAction = () => void;
export type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
export type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: UnsavedNavigationAction) => void;
requestDiscard: (action: UnsavedNavigationAction) => void;
};
export const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action(),
requestDiscard: (action) => action()
};
export function useUnsavedChanges() {
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
}
+10 -33
View File
@@ -1,17 +1,18 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router";
import Button from "./Button";
import Dialog from "./Dialog";
import DismissibleAlert from "./DismissibleAlert";
import {
UnsavedChangesContext,
useUnsavedChanges,
type UnsavedChangesContextValue,
type UnsavedChangesRegistration,
type UnsavedNavigationAction
} from "./UnsavedChangesContext";
export type UnsavedNavigationAction = () => void;
export type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
export { useUnsavedChanges } from "./UnsavedChangesContext";
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./UnsavedChangesContext";
export type UnsavedDraftGuardOptions = {
dirty: boolean;
@@ -22,19 +23,6 @@ export type UnsavedDraftGuardOptions = {
enabled?: boolean;
};
type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: UnsavedNavigationAction) => void;
/**
* Route an explicit Discard button through the same confirmation used for
* dirty navigation. The action runs after either saving or discarding.
*/
requestDiscard: (action: UnsavedNavigationAction) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const navigate = useNavigate();
const [registrations, setRegistrations] = useState<Array<{id: number;registration: UnsavedChangesRegistration;}>>([]);
@@ -190,17 +178,6 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
}
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action(),
requestDiscard: (action) => action()
};
export function useUnsavedChanges() {
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
}
export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistration | null) {
const { registerUnsavedChanges } = useUnsavedChanges();
@@ -0,0 +1,29 @@
import type { ComponentProps } from "react";
import PageActionBar, { type PageActionBarProps, type SemanticActionBarScope } from "./PageActionBar";
export type WorkspaceActionScope = Exclude<SemanticActionBarScope, "page">;
export type WorkspaceActionBarProps = PageActionBarProps & {
scope?: WorkspaceActionScope;
};
/**
* Semantic actions for full-canvas workspaces and their collection, detail,
* and editor panes. It deliberately shares the page action engine while using
* compact panel-header geometry.
*/
export default function WorkspaceActionBar({
scope = "workspace",
...props
}: WorkspaceActionBarProps) {
const actionProps = props as ComponentProps<typeof PageActionBar>;
return (
<PageActionBar
{...actionProps}
actionScope={scope}
density="compact"
surface="panel-header"
data-workspace-action-scope={scope}
/>
);
}
+1 -2
View File
@@ -337,8 +337,7 @@ export default function SettingsPage({
actions={editorSection ? (
<PageActionBar
variant="editor"
dirty={editorDirty}
saving={editorSaving}
state={editorSaving ? "saving" : editorDirty ? "dirty" : "clean"}
helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }}
saveAction={{
+4 -2
View File
@@ -135,7 +135,7 @@ export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree";
export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree";
export { default as MetricCard } from "./components/MetricCard";
export type { MetricCardProps } from "./components/MetricCard";
export type { MetricCardProps, MetricDrilldown } from "./components/MetricCard";
export { default as MetricGrid } from "./components/MetricGrid";
export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid";
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
@@ -144,7 +144,9 @@ export { default as PageTitle } from "./components/PageTitle";
export { default as PageLayout, PageHeader } from "./components/PageLayout";
export type { PageArchetype, PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout";
export { default as PageActionBar } from "./components/PageActionBar";
export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, OverviewPageActionBarProps, PageActionBarProps, PageEditorAction, WorkspacePageActionBarProps } from "./components/PageActionBar";
export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, OverviewPageActionBarProps, PageActionBarProps, PageEditorAction, PageEditorState, PageRefreshState, PageReloadAction, SemanticActionBarScope, WorkspacePageActionBarProps } from "./components/PageActionBar";
export { default as WorkspaceActionBar } from "./components/WorkspaceActionBar";
export type { WorkspaceActionBarProps, WorkspaceActionScope } from "./components/WorkspaceActionBar";
export { default as PageScrollViewport } from "./components/PageScrollViewport";
export type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
+51 -6
View File
@@ -8,7 +8,9 @@ import type {
import type { TemporalDataSelection } from "./temporal";
export const QUICK_ACCESS_LAUNCH_CONTEXT_VERSION = "1" as const;
export const QUICK_ACCESS_LAUNCH_CONTEXT_VERSION = "2" as const;
export const QUICK_ACCESS_REFERENCE_CONTRACT_VERSION = "1" as const;
export const QUICK_ACCESS_RESULT_CONTRACT_VERSION = "1" as const;
export const QUICK_ACCESS_LAUNCH_STATE_KEY = "govoplanQuickAccessLaunch";
export const QUICK_ACCESS_RESULT_EVENT = "govoplan:quick-access-result";
@@ -37,6 +39,7 @@ export function createQuickAccessLaunchContext({
const principal = auth.principal;
return {
contractVersion: QUICK_ACCESS_LAUNCH_CONTEXT_VERSION,
referenceContractVersion: QUICK_ACCESS_REFERENCE_CONTRACT_VERSION,
origin: {
pathname: normalizePathname(pathname),
search: normalizeSearch(search),
@@ -61,7 +64,9 @@ export function createQuickAccessLaunchContext({
? {
viewId: viewContext.activeViewId,
revisionId: viewContext.activeRevisionId,
name: viewContext.activeViewName
name: viewContext.activeViewName,
recommendedToolIds: viewContext.presentation?.quickAccessRecommendedToolIds ?? [],
focusedToolIds: viewContext.presentation?.quickAccessFocusedToolIds ?? []
}
: null
};
@@ -78,7 +83,11 @@ export function quickAccessLaunchContextFromState(
): QuickAccessLaunchContext | null {
if (!isRecord(value)) return null;
const candidate = value[QUICK_ACCESS_LAUNCH_STATE_KEY];
if (!isRecord(candidate) || candidate.contractVersion !== QUICK_ACCESS_LAUNCH_CONTEXT_VERSION) {
if (
!isRecord(candidate)
|| candidate.contractVersion !== QUICK_ACCESS_LAUNCH_CONTEXT_VERSION
|| candidate.referenceContractVersion !== QUICK_ACCESS_REFERENCE_CONTRACT_VERSION
) {
return null;
}
if (!isRecord(candidate.origin) || typeof candidate.origin.pathname !== "string") {
@@ -101,12 +110,48 @@ export function quickAccessReturnPath(context: QuickAccessLaunchContext): string
export function dispatchQuickAccessResult(
toolId: string,
launchContext: QuickAccessLaunchContext,
result: QuickAccessResult
): void {
if (typeof window === "undefined") return;
result: QuickAccessResult,
returnedReferenceKinds?: readonly string[]
): boolean {
if (
typeof window === "undefined"
|| !isQuickAccessResultAllowed(result, launchContext, returnedReferenceKinds)
) return false;
window.dispatchEvent(new CustomEvent(QUICK_ACCESS_RESULT_EVENT, {
detail: { toolId, launchContext, result }
}));
return true;
}
export function isQuickAccessResultAllowed(
result: QuickAccessResult,
launchContext: QuickAccessLaunchContext,
returnedReferenceKinds?: readonly string[]
): boolean {
if (result.contractVersion !== QUICK_ACCESS_RESULT_CONTRACT_VERSION) return false;
if (result.outcome === "cancelled") {
return result.action === undefined
&& result.reference === undefined
&& (result.reason === undefined
|| (["user", "dismissed", "unavailable", "failed"] as const).includes(result.reason));
}
if (result.outcome !== "completed") return false;
if (!(["created", "selected", "updated", "completed"] as const).includes(result.action)) return false;
if (!result.reference) return true;
if (
result.reference.tenantId !== launchContext.tenantId
|| !result.reference.ownerModule
|| !result.reference.kind
|| !result.reference.objectId
|| (result.reference.path !== undefined
&& result.reference.path !== null
&& (!result.reference.path.startsWith("/") || result.reference.path.startsWith("//")))
) return false;
if (returnedReferenceKinds) {
const returnedKind = `${result.reference.ownerModule}.${result.reference.kind}`;
if (!returnedReferenceKinds.includes(returnedKind)) return false;
}
return true;
}
function normalizePathname(value: string): string {
+6 -1
View File
@@ -182,6 +182,7 @@ function productAreasFromMetadata(info: PlatformModuleInfo): ProductAreaContribu
function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessToolMetadata[] {
return (info.frontend?.quick_access_tools ?? []).map((tool) => ({
contractVersion: tool.contract_version,
id: tool.id,
moduleId: tool.module_id,
categoryId: tool.category_id,
@@ -194,7 +195,11 @@ function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessTool
anyOf: tool.required_any,
order: tool.order,
defaultEnabled: tool.default_enabled,
modes: tool.modes
modes: tool.modes,
availability: tool.availability,
acceptedReferenceKinds: tool.accepted_reference_kinds,
returnedReferenceKinds: tool.returned_reference_kinds,
helpContextId: tool.help_context_id
}));
}
+10 -1
View File
@@ -105,6 +105,9 @@
.page-dirty-state { display: inline-flex; align-items: center; gap: 6px; min-height: 28px; color: var(--text-soft); font-size: 12px; font-weight: 700; white-space: nowrap; }
.page-dirty-state::before { width: 8px; height: 8px; border-radius: var(--radius-pill); background: var(--success); content: ""; }
.page-dirty-state-dirty::before { background: var(--warning); }
.page-dirty-state-invalid::before,
.page-dirty-state-save-failed::before,
.page-dirty-state-conflict::before { background: var(--danger); }
.page-dirty-state-saving::before { background: var(--accent); }
.app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
@@ -272,7 +275,7 @@
.metric-group-minimum-compact { --metric-group-column-minimum: 112px; }
.metric-group-minimum-default { --metric-group-column-minimum: 140px; }
.metric-group-minimum-wide { --metric-group-column-minimum: 180px; }
.metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); }
.metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); display: flex; flex-direction: column; min-width: 0; }
.metric-card-density-compact { padding: 10px 12px; border-top-width: 1px; box-shadow: none; }
.metric-card-density-compact .metric-label { font-size: 11px; }
.metric-card-density-compact .metric-value { margin-top: 5px; font-size: 16px; }
@@ -283,6 +286,12 @@
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
.metric-card-drilldown-slot { margin-top: auto; padding-top: 10px; }
.metric-card-drilldown.btn { min-height: 28px; max-width: 100%; padding: 3px 0; color: var(--text-strong); justify-content: flex-start; text-align: left; }
.metric-card-drilldown.btn:hover:not(:disabled), .metric-card-drilldown.btn:focus-visible { color: var(--text-strong); text-decoration: underline; }
.metric-card-drilldown.btn svg { flex: 0 0 auto; }
.metric-card-density-compact .metric-card-drilldown-slot { padding-top: 6px; }
.metric-card-density-compact .metric-card-drilldown.btn { min-height: 24px; font-size: 12px; }
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
.wizard-card { width: min(980px, 100%); background: var(--panel); border: var(--border-line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
+27 -1
View File
@@ -284,6 +284,7 @@ export type ProductAreaContribution = {
};
export type QuickAccessToolMetadata = {
contractVersion: "1";
id: string;
moduleId: string;
categoryId: string;
@@ -297,6 +298,10 @@ export type QuickAccessToolMetadata = {
order?: number;
defaultEnabled?: boolean;
modes?: string[];
availability: "global" | "active_object";
acceptedReferenceKinds: string[];
returnedReferenceKinds: string[];
helpContextId?: string | null;
};
export type PlatformRouteContext = {
@@ -493,6 +498,8 @@ export type ViewPresentation = {
navigationMode?: "grouped" | "flat";
productAreaOrder?: string[];
productAreaLabels?: Record<string, string>;
quickAccessRecommendedToolIds?: string[];
quickAccessFocusedToolIds?: string[];
};
export type ViewSelectorProps = {
@@ -566,7 +573,8 @@ export type ActiveObjectReference = {
};
export type QuickAccessLaunchContext = {
contractVersion: "1";
contractVersion: "2";
referenceContractVersion: "1";
origin: {
pathname: string;
search: string;
@@ -589,12 +597,24 @@ export type QuickAccessLaunchContext = {
viewId: string;
revisionId: string | null;
name: string | null;
recommendedToolIds: string[];
focusedToolIds: string[];
} | null;
};
export type QuickAccessCancellationReason = "user" | "dismissed" | "unavailable" | "failed";
export type QuickAccessResult = {
contractVersion: "1";
outcome: "completed";
action: "created" | "selected" | "updated" | "completed";
reference?: ActiveObjectReference | null;
} | {
contractVersion: "1";
outcome: "cancelled";
reason?: QuickAccessCancellationReason;
action?: never;
reference?: never;
};
export type QuickAccessToolRenderContext = PlatformRouteContext & {
@@ -602,6 +622,7 @@ export type QuickAccessToolRenderContext = PlatformRouteContext & {
active: boolean;
launchContext: QuickAccessLaunchContext;
complete: (result: QuickAccessResult) => void;
cancel: (reason?: QuickAccessCancellationReason) => void;
};
export type QuickAccessToolContribution = {
@@ -1179,6 +1200,11 @@ export type PlatformFrontendModuleInfo = {
order: number;
default_enabled: boolean;
modes: string[];
contract_version: "1";
availability: "global" | "active_object";
accepted_reference_kinds: string[];
returned_reference_kinds: string[];
help_context_id?: string | null;
}>;
};
+64 -4
View File
@@ -1,6 +1,7 @@
import type { AuthInfo } from "../src/types";
import type { AuthInfo, QuickAccessResult } from "../src/types";
import {
createQuickAccessLaunchContext,
isQuickAccessResultAllowed,
quickAccessLaunchContextFromState,
quickAccessLaunchState,
quickAccessReturnPath
@@ -57,15 +58,21 @@ const context = createQuickAccessLaunchContext({
locked: false,
availableViews: [],
provenance: [],
diagnostics: []
diagnostics: [],
presentation: {
quickAccessRecommendedToolIds: ["mail.messages"],
quickAccessFocusedToolIds: ["mail.messages", "files.recent"]
}
}
});
assert(context.contractVersion === "1", "launch context must be explicitly versioned");
assert(context.contractVersion === "2", "launch context must be explicitly versioned");
assert(context.referenceContractVersion === "1", "reference payloads must be independently versioned");
assert(context.activeObject?.objectId === "case-1", "active object reference must survive launch");
assert(context.actingContext?.assignmentId === "assignment-1", "acting assignment must survive launch");
assert(context.temporalContext.validityMode === "at", "temporal selection must survive launch");
assert(context.viewContext?.revisionId === "view-revision-3", "exact View revision must survive launch");
assert(context.viewContext?.recommendedToolIds[0] === "mail.messages", "View recommendations must survive launch without becoming authority");
assert(
quickAccessReturnPath(context) === "/cases/case-1?tab=history#revision-4",
"return path must preserve route, query and fragment"
@@ -75,7 +82,7 @@ assert(
"router state must decode a valid launch context"
);
assert(
quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "2" } }) === null,
quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "3" } }) === null,
"unknown launch context versions must fail closed"
);
@@ -86,3 +93,56 @@ const crossTenant = createQuickAccessLaunchContext({
temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
});
assert(crossTenant.activeObject === null, "cross-tenant object references must be discarded");
const selectedFileResult = {
contractVersion: "1",
outcome: "completed",
action: "selected",
reference: {
ownerModule: "files",
kind: "file-version",
objectId: "version-1",
tenantId: "tenant-1",
label: "Permit evidence.pdf",
path: "/files?version=version-1"
}
} as const;
assert(
isQuickAccessResultAllowed(selectedFileResult, context, ["files.file-version"]),
"a declared same-tenant result reference is accepted"
);
assert(
!isQuickAccessResultAllowed(
{ ...selectedFileResult, reference: { ...selectedFileResult.reference, tenantId: "tenant-2" } },
context,
["files.file-version"]
),
"cross-tenant result references fail closed"
);
assert(
!isQuickAccessResultAllowed(selectedFileResult, context, ["records.record"]),
"undeclared result-reference kinds fail closed"
);
assert(
isQuickAccessResultAllowed({ contractVersion: "1", outcome: "cancelled", reason: "user" }, context),
"explicit cancellation is a valid terminal result"
);
assert(
!isQuickAccessResultAllowed(
{ ...selectedFileResult, outcome: "unknown" } as unknown as QuickAccessResult,
context,
["files.file-version"]
),
"unknown result outcomes fail closed"
);
assert(
!isQuickAccessResultAllowed(
{
...selectedFileResult,
reference: { ...selectedFileResult.reference, path: "//outside.example/files/version-1" }
},
context,
["files.file-version"]
),
"protocol-relative result paths fail closed"
);
+25 -4
View File
@@ -21,6 +21,7 @@ import SelectionList, { SelectionListItem, SelectionListItemContent } from "../s
import StatePanel from "../src/components/StatePanel";
import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
// @ts-expect-error Refreshable pages must provide a Reload action.
const refreshableWithoutReload = <PageActionBar variant="detail" refreshable />;
@@ -52,9 +53,9 @@ const editorActionBarMarkup = renderToStaticMarkup(
<PageActionBar
variant="editor"
refreshable
dirty
state="dirty"
label="Editor actions"
reloadAction={<button type="button">Reload</button>}
reloadAction={{ onReload: () => undefined, label: "Reload" }}
contextActions={<button type="button">Preview</button>}
helpAction={<button type="button">Help</button>}
destructiveActions={<button type="button">Delete</button>}
@@ -73,13 +74,14 @@ assert(editorActionBarMarkup.indexOf('data-page-action-slot="reload"') < editorA
assert(editorActionBarMarkup.indexOf('data-page-action-slot="help"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "help precedes editor persistence actions");
assert(editorActionBarMarkup.indexOf('data-page-action-slot="destructive"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "destructive editor actions are separated from persistence actions");
assert(editorActionBarMarkup.includes('data-page-action-separation="destructive"'), "destructive actions expose their visual boundary");
assert(editorActionBarMarkup.includes('role="group" aria-label="Destructive actions"'), "destructive actions expose an accessible named group");
assert(editorActionBarMarkup.indexOf('data-page-action-slot="discard"') < editorActionBarMarkup.indexOf('data-page-action-slot="save"'), "save remains the far-right editor action");
const cleanEditorActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageActionBar
variant="editor"
dirty={false}
state="clean"
discardAction={{ label: "Discard" }}
saveAction={{ label: "Save" }}
/>
@@ -94,13 +96,32 @@ const collectionActionBarMarkup = renderToStaticMarkup(
<PageActionBar
variant="collection"
refreshable
reloadAction={<button type="button">Reload</button>}
reloadAction={{ onReload: () => undefined, label: "Reload" }}
createAction={<button type="button">Create</button>}
/>
</PlatformLanguageProvider>
);
assert(collectionActionBarMarkup.indexOf('data-page-action-slot="reload"') < collectionActionBarMarkup.indexOf('data-page-action-slot="create"'), "collection creation remains the far-right action");
const workspaceEditorActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<WorkspaceActionBar
scope="editor-pane"
variant="editor"
state="invalid"
refreshable
reloadAction={{ onReload: () => undefined, loading: true }}
destructiveActions={<button type="button">Delete</button>}
discardAction={{ label: "Discard" }}
saveAction={{ label: "Save" }}
/>
</PlatformLanguageProvider>
);
assert(workspaceEditorActionBarMarkup.includes('data-workspace-action-scope="editor-pane"'), "workspace actions expose their pane scope");
assert(workspaceEditorActionBarMarkup.includes('data-page-refresh-state="reloading"'), "reload activity is centrally projected");
assert(workspaceEditorActionBarMarkup.includes('data-page-dirty-state="invalid"'), "invalid editor state remains visible");
assert((workspaceEditorActionBarMarkup.match(/disabled=""/g) ?? []).length >= 2, "invalid editors disable Reload while active and Save while invalid");
const gridMarkup = renderToStaticMarkup(
<ContentGrid columns={3} gap="compact" collapseAt="wide">
<GridItem>A</GridItem>
+38 -1
View File
@@ -17,10 +17,47 @@ const translatedMarkup = renderToStaticMarkup(
</PlatformLanguageProvider>
);
assert(translatedMarkup.includes('class="metric-card metric-info"'), "the visual tone is preserved");
assert(translatedMarkup.includes('class="metric-card metric-info '), "the visual tone is preserved");
assert(translatedMarkup.includes('class="metric-label">Installed modules</div>'), "the label is translated");
assert(translatedMarkup.includes('class="metric-value">Core only</div>'), "a string value is translated");
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved");
assert(!numericMarkup.includes("data-metric-drilldown"), "summary-only metrics remain non-interactive");
const linkedMarkup = renderToStaticMarkup(
<MetricCard
label="Recipients"
value={17}
drilldown={{ label: "Review recipients", href: "/campaigns/42/recipients" }}
/>
);
assert(linkedMarkup.includes('data-metric-drilldown="link"'), "link drill-downs have an explicit affordance");
assert(linkedMarkup.includes('href="/campaigns/42/recipients"'), "link drill-downs preserve their destination");
assert(linkedMarkup.includes("Review recipients"), "link drill-downs name the resulting detail");
const actionMarkup = renderToStaticMarkup(
<MetricCard
label="Failures"
value={2}
drilldown={{ label: "Show failures", onActivate: () => undefined }}
/>
);
assert(actionMarkup.includes('data-metric-drilldown="action"'), "in-page drill-downs render as buttons");
assert(actionMarkup.includes('type="button"'), "in-page drill-downs do not submit an enclosing form");
const disabledMarkup = renderToStaticMarkup(
<MetricCard
label="Suppressed"
value="—"
drilldown={{
label: "Show records",
onActivate: () => undefined,
disabledReason: "Individual records are privacy-suppressed"
}}
/>
);
assert(disabledMarkup.includes('class="disabled-action-tooltip"'), "disabled drill-downs retain the shared explanation trigger");
assert(disabledMarkup.includes('tabindex="0"'), "disabled drill-down explanations remain keyboard reachable");
assert(disabledMarkup.includes("disabled"), "blocked drill-down actions cannot run");
+1 -1
View File
@@ -13,7 +13,7 @@ const standaloneMarkup = renderToStaticMarkup(
archetype="collection"
title="Shared page"
description="One page frame"
actions={<PageActionBar variant="collection" refreshable reloadAction={<button type="button">Reload</button>} />}
actions={<PageActionBar variant="collection" refreshable reloadAction={{ onReload: () => undefined }} />}
error="Could not load"
success="Saved"
interfaceId="test.page"
+10 -1
View File
@@ -1,7 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"]
"types": ["node"],
"paths": {
"@govoplan/core-webui": ["./conformance/QuickAccessCoreFacade.ts"],
"@govoplan/core-webui/app": ["./src/app.ts"],
"@govoplan/core-webui/wysiwyg": ["./src/wysiwyg.ts"],
"react": ["./node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"],
"react-router": ["./node_modules/react-router/dist/production/index.d.ts"],
"lucide-react": ["./node_modules/lucide-react/dist/lucide-react.d.ts"]
}
},
"include": ["src", "conformance", "vite.conformance.config.ts"]
}
+8
View File
@@ -5,6 +5,14 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
root: resolve(import.meta.dirname, "conformance"),
plugins: [react()],
resolve: {
alias: {
"@govoplan/core-webui": resolve(import.meta.dirname, "conformance/QuickAccessCoreFacade.ts"),
react: resolve(import.meta.dirname, "node_modules/react"),
"react-router": resolve(import.meta.dirname, "node_modules/react-router"),
"lucide-react": resolve(import.meta.dirname, "node_modules/lucide-react")
}
},
build: {
outDir: resolve(import.meta.dirname, "dist-conformance"),
emptyOutDir: true