diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index 8facd58..c82811c 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -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 1–4 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. diff --git a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md index 4bdf76f..29e30ac 100644 --- a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md +++ b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md @@ -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. diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 10bc160..38c1b49 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -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) diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 1f6015c..9f9e0ac 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -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: diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index e25258f..dc5ed98 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -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, } diff --git a/tests/test_presentation_contract.py b/tests/test_presentation_contract.py index 4544998..903685b 100644 --- a/tests/test_presentation_contract.py +++ b/tests/test_presentation_contract.py @@ -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__": diff --git a/webui/conformance/ConformanceApp.tsx b/webui/conformance/ConformanceApp.tsx index 1dea563..a0056d2 100644 --- a/webui/conformance/ConformanceApp.tsx +++ b/webui/conformance/ConformanceApp.tsx @@ -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 (
@@ -49,9 +53,9 @@ export default function ConformanceApp() { Neu laden} + reloadAction={{ onReload: () => undefined, label: "Neu laden" }} contextActions={} destructiveActions={} discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }} @@ -73,11 +77,12 @@ export default function ConformanceApp() {

Kennzahlen und Eigenschaften

- + setMetricDrilldown("18 offene Aufgaben") }} /> +

{metricDrilldown}

Nachvollziehbare Entscheidung

@@ -98,25 +103,31 @@ export default function ConformanceApp() { primaryLabel="Vorgänge" contentLabel="Ausgewählter Vorgang" primary={ - - - } title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" /> - - - } title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" /> - - + <> + undefined, label: "Vorgänge neu laden" }} contextActions={Vorgänge} createAction={} /> + + + } title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" /> + + + } title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" /> + + + } > - Bearbeiten}> -

Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.

- - - - - - -
+ <> + Anwohnerparkausweis} primaryActions={} destructiveActions={} /> + +

Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.

+ + + + + + +
+
@@ -154,10 +165,36 @@ export default function ConformanceApp() { + {new URLSearchParams(location.search).has("quick-access") ? : null}
); } +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 ; +} + 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; diff --git a/webui/conformance/QuickAccessCoreFacade.ts b/webui/conformance/QuickAccessCoreFacade.ts new file mode 100644 index 0000000..56e9d99 --- /dev/null +++ b/webui/conformance/QuickAccessCoreFacade.ts @@ -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(capabilityName: string): T[] { + void capabilityName; + return []; +} diff --git a/webui/conformance/main.tsx b/webui/conformance/main.tsx index 9c7a1d1..911f907 100644 --- a/webui/conformance/main.tsx +++ b/webui/conformance/main.tsx @@ -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"); diff --git a/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png b/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png index 653f2da..a531baa 100644 Binary files a/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png and b/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png differ diff --git a/webui/conformance/tests/snapshots/shared-ui-light-desktop.png b/webui/conformance/tests/snapshots/shared-ui-light-desktop.png index 69a0d48..c205b34 100644 Binary files a/webui/conformance/tests/snapshots/shared-ui-light-desktop.png and b/webui/conformance/tests/snapshots/shared-ui-light-desktop.png differ diff --git a/webui/conformance/tests/snapshots/shared-ui-light-narrow.png b/webui/conformance/tests/snapshots/shared-ui-light-narrow.png index c693827..be80327 100644 Binary files a/webui/conformance/tests/snapshots/shared-ui-light-narrow.png and b/webui/conformance/tests/snapshots/shared-ui-light-narrow.png differ diff --git a/webui/conformance/tests/ui-conformance.spec.ts b/webui/conformance/tests/ui-conformance.spec.ts index 01b423f..d99d09e 100644 --- a/webui/conformance/tests/ui-conformance.spec.ts +++ b/webui/conformance/tests/ui-conformance.spec.ts @@ -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"); diff --git a/webui/scripts/test-core-interface-patterns.mjs b/webui/scripts/test-core-interface-patterns.mjs index a2bfaec..cfa6e63 100644 --- a/webui/scripts/test-core-interface-patterns.mjs +++ b/webui/scripts/test-core-interface-patterns.mjs @@ -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, //, "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, /[\s\S]*/, "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"); diff --git a/webui/src/components/MetricCard.tsx b/webui/src/components/MetricCard.tsx index 873c0d6..538f9f1 100644 --- a/webui/src/components/MetricCard.tsx +++ b/webui/src/components/MetricCard.tsx @@ -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 & { +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, "onClick" | "role" | "tabIndex"> & { label: ReactNode; value: ReactNode; tone?: "neutral" | "good" | "warning" | "danger" | "info"; @@ -9,6 +31,7 @@ export type MetricCardProps = HTMLAttributes & { 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 (
{renderedLabel}
{renderedValue}
{renderedDetail ?
{renderedDetail}
: null} + {drilldown ? ( +
+ {drilldown.href !== undefined ? ( + + {renderedDrilldownLabel} + + ) : ( + + )} +
+ ) : null} ); } diff --git a/webui/src/components/PageActionBar.tsx b/webui/src/components/PageActionBar.tsx index 9a50c5e..69a4c6b 100644 --- a/webui/src/components/PageActionBar.tsx +++ b/webui/src/components/PageActionBar.tsx @@ -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, "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, "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 ( @@ -87,13 +113,47 @@ function DestructiveSlot({ children }: { children: ReactNode }) { if (!children) return null; return ( - + {children} ); } +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 ( + + ); +} + 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 ? {primaryActions} : null} {destructiveActions} - + - + ); @@ -198,31 +274,46 @@ export default function PageActionBar(props: PageActionBarProps) { - {refreshable ? {reloadAction} : null} + {refreshable ? : null} {contextActions ? {contextActions} : null} {variant === "editor" ? ( - {saving ? savingLabel : dirty ? dirtyLabel : cleanLabel} + {state === "saving" + ? savingLabel + : state === "invalid" + ? invalidLabel + : state === "save-failed" + ? saveFailedLabel + : state === "conflict" + ? conflictLabel + : state === "dirty" + ? dirtyLabel + : cleanLabel} ) : null} {helpAction ? {helpAction} : null} diff --git a/webui/src/components/UnsavedChangesContext.ts b/webui/src/components/UnsavedChangesContext.ts new file mode 100644 index 0000000..f7a833c --- /dev/null +++ b/webui/src/components/UnsavedChangesContext.ts @@ -0,0 +1,30 @@ +import { createContext, useContext } from "react"; + +export type UnsavedNavigationAction = () => void; + +export type UnsavedChangesRegistration = { + title?: string; + message?: string; + onSave: () => boolean | Promise; + onDiscard?: () => void; +}; + +export type UnsavedChangesContextValue = { + hasUnsavedChanges: boolean; + registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void; + requestNavigation: (action: UnsavedNavigationAction) => void; + requestDiscard: (action: UnsavedNavigationAction) => void; +}; + +export const UnsavedChangesContext = createContext(null); + +const fallbackUnsavedChangesContext: UnsavedChangesContextValue = { + hasUnsavedChanges: false, + registerUnsavedChanges: () => () => undefined, + requestNavigation: (action) => action(), + requestDiscard: (action) => action() +}; + +export function useUnsavedChanges() { + return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext; +} diff --git a/webui/src/components/UnsavedChangesGuard.tsx b/webui/src/components/UnsavedChangesGuard.tsx index cadabe3..0e8f027 100644 --- a/webui/src/components/UnsavedChangesGuard.tsx +++ b/webui/src/components/UnsavedChangesGuard.tsx @@ -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; - 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(null); - export function UnsavedChangesProvider({ children }: {children: ReactNode;}) { const navigate = useNavigate(); const [registrations, setRegistrations] = useState>([]); @@ -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(); diff --git a/webui/src/components/WorkspaceActionBar.tsx b/webui/src/components/WorkspaceActionBar.tsx new file mode 100644 index 0000000..2a587f7 --- /dev/null +++ b/webui/src/components/WorkspaceActionBar.tsx @@ -0,0 +1,29 @@ +import type { ComponentProps } from "react"; +import PageActionBar, { type PageActionBarProps, type SemanticActionBarScope } from "./PageActionBar"; + +export type WorkspaceActionScope = Exclude; + +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; + return ( + + ); +} diff --git a/webui/src/features/settings/SettingsPage.tsx b/webui/src/features/settings/SettingsPage.tsx index 93f08f5..6f22940 100644 --- a/webui/src/features/settings/SettingsPage.tsx +++ b/webui/src/features/settings/SettingsPage.tsx @@ -337,8 +337,7 @@ export default function SettingsPage({ actions={editorSection ? ( } discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }} saveAction={{ diff --git a/webui/src/index.ts b/webui/src/index.ts index 0e78369..0011cb9 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/platform/launchContext.ts b/webui/src/platform/launchContext.ts index e4659dc..8907603 100644 --- a/webui/src/platform/launchContext.ts +++ b/webui/src/platform/launchContext.ts @@ -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 { diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index a9148ae..56bbc76 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -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 })); } diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index 57f8551..8609787 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -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; } diff --git a/webui/src/types.ts b/webui/src/types.ts index a54173e..7aa4880 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -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; + 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; }>; }; diff --git a/webui/tests/launch-context.test.ts b/webui/tests/launch-context.test.ts index cc2723d..2005a41 100644 --- a/webui/tests/launch-context.test.ts +++ b/webui/tests/launch-context.test.ts @@ -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" +); diff --git a/webui/tests/layout-primitives.test.tsx b/webui/tests/layout-primitives.test.tsx index 717745d..9dade47 100644 --- a/webui/tests/layout-primitives.test.tsx +++ b/webui/tests/layout-primitives.test.tsx @@ -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 = ; @@ -52,9 +53,9 @@ const editorActionBarMarkup = renderToStaticMarkup( Reload} + reloadAction={{ onReload: () => undefined, label: "Reload" }} contextActions={} helpAction={} destructiveActions={} @@ -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( @@ -94,13 +96,32 @@ const collectionActionBarMarkup = renderToStaticMarkup( Reload} + reloadAction={{ onReload: () => undefined, label: "Reload" }} createAction={} /> ); 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( + + undefined, loading: true }} + destructiveActions={} + discardAction={{ label: "Discard" }} + saveAction={{ label: "Save" }} + /> + +); +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( A diff --git a/webui/tests/metric-card.test.tsx b/webui/tests/metric-card.test.tsx index 0891f7d..337b67e 100644 --- a/webui/tests/metric-card.test.tsx +++ b/webui/tests/metric-card.test.tsx @@ -17,10 +17,47 @@ const translatedMarkup = renderToStaticMarkup( ); -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'), "the label is translated"); assert(translatedMarkup.includes('class="metric-value">Core only'), "a string value is translated"); assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text"); const numericMarkup = renderToStaticMarkup(); assert(numericMarkup.includes('class="metric-value">7'), "numeric values are preserved"); +assert(!numericMarkup.includes("data-metric-drilldown"), "summary-only metrics remain non-interactive"); + +const linkedMarkup = renderToStaticMarkup( + +); +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( + 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( + 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"); diff --git a/webui/tests/page-layout.test.tsx b/webui/tests/page-layout.test.tsx index 086fda1..33d383f 100644 --- a/webui/tests/page-layout.test.tsx +++ b/webui/tests/page-layout.test.tsx @@ -13,7 +13,7 @@ const standaloneMarkup = renderToStaticMarkup( archetype="collection" title="Shared page" description="One page frame" - actions={Reload} />} + actions={ undefined }} />} error="Could not load" success="Saved" interfaceId="test.page" diff --git a/webui/tsconfig.conformance.json b/webui/tsconfig.conformance.json index 70295f2..6720519 100644 --- a/webui/tsconfig.conformance.json +++ b/webui/tsconfig.conformance.json @@ -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"] } diff --git a/webui/vite.conformance.config.ts b/webui/vite.conformance.config.ts index e5cfb6a..18158db 100644 --- a/webui/vite.conformance.config.ts +++ b/webui/vite.conformance.config.ts @@ -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