diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index dc11c75..8facd58 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -17,7 +17,7 @@ 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 action bar | Named collection, detail, and editor action placement composed over the responsive toolbar | Reload stays leading; collection Create, editor Save, and detail consequences occupy stable trailing slots; read-only pages do not invent Save; modules retain wording, permissions, blockers, and effects | `PageActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, `layout-primitives.test.tsx`, Payments, 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` | | 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 | diff --git a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md index 88fab5f..4bdf76f 100644 --- a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md +++ b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md @@ -7,6 +7,8 @@ wording, authorization, consequences, and data state. ## Required Page Frame - Use `PageLayout` for every headed standalone, workspace, or embedded page. +- Declare exactly one semantic `archetype`; do not infer page intent from the + `mode`, which controls geometry and scroll ownership only. - Use `WorkspaceFrame` for a full-height module surface and `WorkspaceLayout` only where navigation/content or list/detail panes are genuinely part of the interaction. @@ -16,7 +18,22 @@ wording, authorization, consequences, and data state. - Do not reproduce shared page padding, heading, toolbar, form-grid, section, table, dialog, or breakpoint CSS in a module. -## Page Action Archetypes +## Semantic Page Archetypes + +| Archetype | Use when | +| --- | --- | +| `overview` | The page summarizes health, metrics, or several peer areas without owning one primary collection or draft. | +| `collection` | The primary object is a searchable/listable collection and Create, when available, applies to that collection. | +| `detail` | The page primarily presents one record, report, or immutable projection. | +| `editor` | The page owns one explicit draft with Save and Discard behavior. | +| `workspace` | The page coordinates several panes, stages, or task-local operations that cannot honestly be reduced to one record or draft. | + +The archetype remains stable for the current interaction. A page may switch +from `overview` to `editor` when the user explicitly enters configuration +mode. It must not call a page an editor merely because a dialog or an inline +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 @@ -24,17 +41,36 @@ across modules. | Page kind | Leading group | Trailing group | | --- | --- | --- | -| Collection | Reload, then collection context such as export | Help, then Create at the far right | -| Detail | Reload, then object context | Help, ordinary primary actions, then consequential actions | -| Editor | Reload, then editor context such as preview | Help, Discard, then Save at the far right | +| Overview | Reload when refreshable, then context | Help, then ordinary primary actions | +| Collection | Reload when refreshable, then collection context such as export | Help, then Create at the far right | +| Detail | Reload when refreshable, then object context | Help, ordinary primary actions, then a separated destructive group | +| Editor | Reload only when refresh is a distinct safe operation, then context | Dirty state, Help, ordinary primary actions, separated destructive actions, Discard, then Save at the far right | +| Workspace | Reload when the coordinated projection can become stale, then task context | Help, ordinary primary actions, then a separated destructive group | -Reload means re-fetch or re-evaluate the current surface. It remains present -when the page can become stale. Create is a collection-wide action and is not -duplicated in a 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. +Reload means re-fetch or re-evaluate the current surface. A page declaring +`refreshable` must provide it, and a non-refreshable page must not use Reload as +a synonym for Cancel, Reset, or Discard. Reload never silently destroys a dirty +draft. Create is a collection-wide action and is not duplicated in a +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. -`PageActionBar` controls placement only. Actions continue to use central +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. + +Destructive page actions use `destructiveActions`; never put a danger action in +`contextActions` or the ordinary primary group. Core renders a persistent +visual and semantic boundary before this group. In an editor it precedes the +Discard/Save pair, keeping Save in the final keyboard and visual position. + +`PageActionBar` controls non-editor placement and owns the standard editor +persistence buttons. Other actions continue to use central `Button`, `IconButton`, or `TableActionGroup` components. When an action is visible but unavailable because of permission, target, policy, state, or validation, keep it in its stable slot and supply `disabledReason`. Do not @@ -65,8 +101,11 @@ silently hide a normally applicable action. ## Review Evidence -Every new or changed page should have structural evidence for its page frame, -semantic action archetype and slot order, shared component usage, stable -disabled actions, and module-owned help identity. Keyboard and narrow-layout -checks must confirm that all commands remain reachable in DOM order and that -the trailing group stays visually trailing after wrapping. +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. diff --git a/docs/UI_UX_DECISION_LEDGER.md b/docs/UI_UX_DECISION_LEDGER.md index 0c19455..925073e 100644 --- a/docs/UI_UX_DECISION_LEDGER.md +++ b/docs/UI_UX_DECISION_LEDGER.md @@ -57,7 +57,8 @@ contestability, responsibility, and traceability at the point of action. | UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs | | UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs | | UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI | -| UX-034 | Headed pages use the semantic `PageActionBar` slots instead of arranging primary page actions ad hoc. Collections place Reload in the leading group and Create at the far right; details place Reload first and contextual, primary, then consequential actions in stable groups; editors place Reload first and Discard immediately before the far-right Save. Read-only pages do not invent a meaningless Save action, and unavailable actions retain shared actionable blocker explanations. | Accepted | Core and all module WebUIs | +| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: a refreshable page must provide Reload in the leading slot; collections keep Create far right; read-only pages do not invent Save. | Accepted | Core and all module WebUIs | +| UX-035 | Editor action bars expose clean, dirty, and saving state; always retain Discard immediately before the far-right Save; centrally disable both while clean or saving; and participate in the unsaved-change navigation guard. Danger actions occupy the explicit separated destructive group after ordinary actions and before editor persistence. | Accepted | Core and all module WebUIs | ## Confirmed Implementation Decisions diff --git a/webui/conformance/ConformanceApp.tsx b/webui/conformance/ConformanceApp.tsx index 83bdebf..1dea563 100644 --- a/webui/conformance/ConformanceApp.tsx +++ b/webui/conformance/ConformanceApp.tsx @@ -33,29 +33,29 @@ import type { AuthInfo } from "../src/types"; export default function ConformanceApp() { const [dialogOpen, setDialogOpen] = useState(false); + const [editorDirty, setEditorDirty] = useState(true); return (
setDialogOpen(true)}>Prüfdialog öffnen} + actions={ setDialogOpen(true)}>Prüfdialog öffnen} />} >

Aktionen, Filter und Status

Neu laden} - contextActions={( - <> - - - - )} - discardAction={} - saveAction={} + contextActions={} + destructiveActions={} + discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }} + saveAction={{ label: "Änderungen speichern", onClick: () => setEditorDirty(false) }} /> diff --git a/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png b/webui/conformance/tests/snapshots/shared-ui-dark-desktop.png index 1c52f5c..653f2da 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 dd6a095..69a0d48 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 6f73cd0..c693827 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 81d88af..01b423f 100644 --- a/webui/conformance/tests/ui-conformance.spec.ts +++ b/webui/conformance/tests/ui-conformance.spec.ts @@ -25,6 +25,20 @@ test("shared components remain accessible and keyboard operable", async ({ page await expectNoAccessibilityViolations(page); const editorActions = page.getByRole("toolbar", { name: "Bearbeitungsaktionen" }); + await expect(page.locator("[data-page-archetype='overview']")).toBeVisible(); + await expect(editorActions).toHaveAttribute("data-page-refreshable", "true"); + await expect(editorActions).toHaveAttribute("data-page-dirty", "true"); + await expect(editorActions.getByRole("status")).toHaveText("Unsaved changes"); + await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCount(1); + await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCSS("border-left-width", "2px"); + const actionSlots = await editorActions.locator("[data-page-action-slot]").evaluateAll((elements) => elements.map((element) => element.getAttribute("data-page-action-slot"))); + expect(actionSlots).toEqual([ + "reload", + "context", + "destructive", + "discard", + "save" + ]); await expect(editorActions.getByRole("button")).toHaveText([ "Neu laden", "Vorschau öffnen", @@ -42,6 +56,11 @@ test("shared components remain accessible and keyboard operable", async ({ page await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeFocused(); await page.keyboard.press("Tab"); await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(editorActions).toHaveAttribute("data-page-dirty", "false"); + await expect(editorActions.getByRole("status")).toHaveText("Saved"); + await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeDisabled(); + await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeDisabled(); const opener = page.getByTestId("open-dialog"); await opener.focus(); @@ -92,5 +111,8 @@ test("narrow layout preserves task order without horizontal overflow", async ({ expect(overflowing).toEqual([]); await expect(page.locator("[data-page-action-archetype='editor'] [data-page-action-group='trailing']")) .toHaveCSS("justify-content", "flex-end"); + const saveRightEdge = await page.locator("[data-page-action-archetype='editor'] [data-page-action-slot='save']").evaluate((element) => element.getBoundingClientRect().right); + const actionBarRightEdge = await page.locator("[data-page-action-archetype='editor']").evaluate((element) => element.getBoundingClientRect().right); + expect(Math.abs(actionBarRightEdge - saveRightEdge)).toBeLessThanOrEqual(1); await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 }); }); diff --git a/webui/scripts/test-core-interface-patterns.mjs b/webui/scripts/test-core-interface-patterns.mjs index edce4e6..a2bfaec 100644 --- a/webui/scripts/test-core-interface-patterns.mjs +++ b/webui/scripts/test-core-interface-patterns.mjs @@ -28,8 +28,8 @@ const layoutStyles = read("src/styles/layout.css"); const authGateStyles = read("src/styles/auth-gate.css"); assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation"); -assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state"); -assert.match(settings, /There are no unsaved interface changes\./, "preference save explains its clean state"); +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(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"); diff --git a/webui/src/components/PageActionBar.tsx b/webui/src/components/PageActionBar.tsx index 3372e2e..9a50c5e 100644 --- a/webui/src/components/PageActionBar.tsx +++ b/webui/src/components/PageActionBar.tsx @@ -1,36 +1,71 @@ -import type { HTMLAttributes, ReactNode } from "react"; +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import type { PlatformInterfaceIdentityProps } from "../types"; import ActionToolbar, { ToolbarGroup } from "./ActionToolbar"; +import Button from "./Button"; + +type RefreshablePageActions = { + refreshable: true; + reloadAction: ReactNode; +} | { + refreshable?: false; + reloadAction?: never; +}; type PageActionBarCommonProps = PlatformInterfaceIdentityProps & Omit, "children"> & { - reloadAction: ReactNode; contextActions?: ReactNode; helpAction?: ReactNode; label?: string; }; -export type CollectionPageActionBarProps = PageActionBarCommonProps & { +export type PageEditorAction = Omit, "children"> & PlatformInterfaceIdentityProps & { + label: ReactNode; + disabledReason?: ReactNode; +}; + +export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & { + variant: "overview"; + primaryActions?: ReactNode; +}; + +export type CollectionPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & { variant: "collection"; createAction?: ReactNode; }; -export type DetailPageActionBarProps = PageActionBarCommonProps & { +export type DetailPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & { variant: "detail"; primaryActions?: ReactNode; - consequentialActions?: ReactNode; + destructiveActions?: ReactNode; }; -export type EditorPageActionBarProps = PageActionBarCommonProps & { +export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & { variant: "editor"; - discardAction: ReactNode; - saveAction: ReactNode; + dirty: boolean; + saving?: boolean; + dirtyLabel?: ReactNode; + cleanLabel?: ReactNode; + savingLabel?: ReactNode; + cleanDisabledReason?: ReactNode; + savingDisabledReason?: ReactNode; + discardAction: PageEditorAction; + saveAction: PageEditorAction; + primaryActions?: ReactNode; + destructiveActions?: ReactNode; +}; + +export type WorkspacePageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & { + variant: "workspace"; + primaryActions?: ReactNode; + destructiveActions?: ReactNode; }; export type PageActionBarProps = + | OverviewPageActionBarProps | CollectionPageActionBarProps | DetailPageActionBarProps - | EditorPageActionBarProps; + | EditorPageActionBarProps + | WorkspacePageActionBarProps; function ActionSlot({ name, children }: { name: string; children: ReactNode }) { return ( @@ -41,11 +76,41 @@ function ActionSlot({ name, children }: { name: string; children: ReactNode }) { } const defaultLabels = { + overview: "Overview page actions", collection: "Collection page actions", detail: "Detail page actions", - editor: "Editor page actions" + editor: "Editor page actions", + workspace: "Workspace page actions" } as const; +function DestructiveSlot({ children }: { children: ReactNode }) { + if (!children) return null; + return ( + + + {children} + + + ); +} + +function EditorAction({ + action, + variant, + disabledReason +}: { + action: PageEditorAction; + variant: "ghost" | "primary"; + disabledReason?: ReactNode; +}) { + const { label, disabledReason: actionDisabledReason, ...buttonProps } = action; + return ( + + ); +} + /** * Semantic action placement for headed pages. * @@ -56,18 +121,33 @@ export default function PageActionBar(props: PageActionBarProps) { const normalizedProps = props as PageActionBarProps & { createAction?: ReactNode; primaryActions?: ReactNode; - consequentialActions?: ReactNode; - discardAction?: ReactNode; - saveAction?: ReactNode; + destructiveActions?: ReactNode; + dirty?: boolean; + saving?: boolean; + dirtyLabel?: ReactNode; + cleanLabel?: ReactNode; + savingLabel?: ReactNode; + cleanDisabledReason?: ReactNode; + savingDisabledReason?: ReactNode; + discardAction?: PageEditorAction; + saveAction?: PageEditorAction; }; const { variant, + refreshable = false, reloadAction, contextActions, helpAction, createAction, primaryActions, - consequentialActions, + destructiveActions, + dirty = false, + saving = false, + dirtyLabel = "Unsaved changes", + cleanLabel = "Saved", + savingLabel = "Saving…", + cleanDisabledReason = "There are no unsaved changes.", + savingDisabledReason = "Changes are already being saved.", discardAction, saveAction, label, @@ -80,20 +160,36 @@ export default function PageActionBar(props: PageActionBarProps) { } = normalizedProps; let trailingActions: ReactNode; - if (variant === "collection") { + if (variant === "overview") { + trailingActions = primaryActions ? {primaryActions} : null; + } else if (variant === "collection") { trailingActions = createAction ? {createAction} : null; } else if (variant === "detail") { trailingActions = ( <> {primaryActions ? {primaryActions} : null} - {consequentialActions ? {consequentialActions} : null} + {destructiveActions} + + ); + } else if (variant === "editor") { + const persistenceDisabledReason = saving ? savingDisabledReason : !dirty ? cleanDisabledReason : undefined; + trailingActions = ( + <> + {primaryActions ? {primaryActions} : null} + {destructiveActions} + + + + + + ); } else { trailingActions = ( <> - {discardAction} - {saveAction} + {primaryActions ? {primaryActions} : null} + {destructiveActions} ); } @@ -111,12 +207,24 @@ export default function PageActionBar(props: PageActionBarProps) { helpModuleId={helpModuleId} helpTopicId={helpTopicId} data-page-action-archetype={variant} + data-page-refreshable={refreshable ? "true" : "false"} + data-page-dirty={variant === "editor" ? (dirty ? "true" : "false") : undefined} > - {reloadAction} + {refreshable ? {reloadAction} : null} {contextActions ? {contextActions} : null} + {variant === "editor" ? ( + + {saving ? savingLabel : dirty ? dirtyLabel : cleanLabel} + + ) : null} {helpAction ? {helpAction} : null} {trailingActions} diff --git a/webui/src/components/PageLayout.tsx b/webui/src/components/PageLayout.tsx index 71ace4f..9aa9a7a 100644 --- a/webui/src/components/PageLayout.tsx +++ b/webui/src/components/PageLayout.tsx @@ -7,6 +7,7 @@ import PageScrollViewport from "./PageScrollViewport"; import PageTitle from "./PageTitle"; export type PageLayoutMode = "standalone" | "workspace" | "embedded"; +export type PageArchetype = "overview" | "collection" | "detail" | "editor" | "workspace"; export type PageHeaderProps = { title: ReactNode; @@ -48,6 +49,8 @@ export function PageHeader({ } export type PageLayoutProps = PlatformInterfaceIdentityProps & { + /** Semantic page intent. This is independent from viewport/layout geometry. */ + archetype: PageArchetype; title: ReactNode; description?: ReactNode; actions?: ReactNode; @@ -70,6 +73,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & { }; export default function PageLayout({ + archetype, title, description, actions, @@ -107,6 +111,7 @@ export default function PageLayout({ const layout = (
{ + if (active === "profile") { + setProfileName(auth.user.display_name || ""); + setTenantProfileName(auth.user.tenant_display_name || ""); + } else { + resetUiPreferences(); + } + }; + const saveEditor = () => { + if (active === "profile") void saveProfile(); + else void saveUiPreferences(); + }; + return ( } + actions={editorSection ? ( + } + discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }} + saveAction={{ + label: active === "profile" ? "i18n:govoplan-core.save_profile.f597c0e8" : "i18n:govoplan-core.save_preferences.0f1a7e44", + onClick: saveEditor + }} + /> + ) : ( + } /> + )} mode="workspace" > @@ -333,16 +365,6 @@ export default function SettingsPage({ -
- -
{profileResult && {profileResult}} @@ -418,16 +440,6 @@ export default function SettingsPage({ checked={reduceMotion} onChange={setReduceMotion} /> -
- -
{uiResult && {uiResult}} @@ -481,16 +493,6 @@ export default function SettingsPage({ disabled onChange={() => undefined} /> -
- -
{uiResult && {uiResult}} diff --git a/webui/src/index.ts b/webui/src/index.ts index 354d2e3..0e78369 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -142,9 +142,9 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel"; export { default as PageTitle } from "./components/PageTitle"; export { default as PageLayout, PageHeader } from "./components/PageLayout"; -export type { PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout"; +export type { PageArchetype, PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout"; export { default as PageActionBar } from "./components/PageActionBar"; -export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, PageActionBarProps } from "./components/PageActionBar"; +export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, OverviewPageActionBarProps, PageActionBarProps, PageEditorAction, WorkspacePageActionBarProps } from "./components/PageActionBar"; 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/styles/layout.css b/webui/src/styles/layout.css index 8e72c93..57f8551 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -101,6 +101,11 @@ .action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; } .page-action-slot { min-width: 0; display: inline-flex; align-items: center; } .page-action-bar-trailing { flex: 0 1 auto; } +.page-action-destructive-group { display: inline-flex; align-items: center; gap: var(--space-2); border-inline-start: 2px solid var(--line-dark); margin-inline-start: 4px; padding-inline-start: var(--space-2); } +.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-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); } .workspace-layout-primary, diff --git a/webui/tests/layout-primitives.test.tsx b/webui/tests/layout-primitives.test.tsx index ae3c6e1..717745d 100644 --- a/webui/tests/layout-primitives.test.tsx +++ b/webui/tests/layout-primitives.test.tsx @@ -22,6 +22,13 @@ import StatePanel from "../src/components/StatePanel"; import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceFrame from "../src/components/WorkspaceFrame"; +// @ts-expect-error Refreshable pages must provide a Reload action. +const refreshableWithoutReload = ; +// @ts-expect-error Editor pages must declare dirty state and persistence actions. +const editorWithoutPersistence = ; +void refreshableWithoutReload; +void editorWithoutPersistence; + const toolbarMarkup = renderToStaticMarkup( @@ -44,26 +51,49 @@ const editorActionBarMarkup = renderToStaticMarkup( Reload} contextActions={} helpAction={} - discardAction={} - saveAction={} + destructiveActions={} + discardAction={{ label: "Discard" }} + saveAction={{ label: "Save" }} /> ); assert(editorActionBarMarkup.includes('data-page-action-archetype="editor"'), "page action bars expose their semantic archetype"); assert(editorActionBarMarkup.includes('aria-label="Editor actions"'), "page action bars are named toolbars"); +assert(editorActionBarMarkup.includes('data-page-refreshable="true"'), "refreshable pages expose their refresh contract"); +assert(editorActionBarMarkup.includes('data-page-dirty="true"'), "editors expose their dirty state"); +assert(editorActionBarMarkup.includes('data-page-dirty-state="dirty"'), "dirty editors announce unsaved changes"); assert(editorActionBarMarkup.indexOf('data-page-action-slot="reload"') < editorActionBarMarkup.indexOf('data-page-action-slot="context"'), "reload precedes contextual actions"); 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.indexOf('data-page-action-slot="discard"') < editorActionBarMarkup.indexOf('data-page-action-slot="save"'), "save remains the far-right editor action"); +const cleanEditorActionBarMarkup = renderToStaticMarkup( + + + +); +assert(cleanEditorActionBarMarkup.includes('data-page-dirty-state="clean"'), "clean editors announce their persisted state"); +assert((cleanEditorActionBarMarkup.match(/disabled=""/g) ?? []).length === 2, "clean editors keep Save and Discard visible but disabled"); +assert((cleanEditorActionBarMarkup.match(/disabled-action-tooltip/g) ?? []).length === 2, "clean editor actions expose explanations for unavailable persistence"); + const collectionActionBarMarkup = renderToStaticMarkup( Reload} createAction={} /> diff --git a/webui/tests/page-layout.test.tsx b/webui/tests/page-layout.test.tsx index fd5783f..086fda1 100644 --- a/webui/tests/page-layout.test.tsx +++ b/webui/tests/page-layout.test.tsx @@ -3,15 +3,17 @@ function assert(condition: unknown, message = "assertion failed"): void { } import { renderToStaticMarkup } from "react-dom/server"; +import PageActionBar from "../src/components/PageActionBar"; import PageLayout from "../src/components/PageLayout"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; const standaloneMarkup = renderToStaticMarkup( Reload} + actions={Reload} />} error="Could not load" success="Saved" interfaceId="test.page" @@ -30,13 +32,14 @@ assert(standaloneMarkup.includes("page-layout-actions"), "route actions use the assert(standaloneMarkup.includes('data-interface-id="test.page"'), "page identity reaches the shared frame"); assert(standaloneMarkup.includes('data-help-context-id="test.page.help"'), "context help reaches the shared frame"); assert(standaloneMarkup.includes('data-help-module-id="test-module"'), "documentation ownership reaches the shared frame"); +assert(standaloneMarkup.includes('data-page-archetype="collection"'), "page intent is independent from layout geometry"); assert(standaloneMarkup.includes("Could not load"), "page errors use the shared alert region"); assert(standaloneMarkup.includes("Saved"), "page success notices use the shared alert region"); assert(standaloneMarkup.includes("Page content"), "page content is preserved"); const embeddedMarkup = renderToStaticMarkup( - + Embedded content @@ -50,6 +53,7 @@ assert(embeddedMarkup.includes('data-help-documentation-type="admin"'), "embedde const workspaceMarkup = renderToStaticMarkup( Version 3

} mode="workspace" @@ -68,7 +72,7 @@ assert(workspaceMarkup.includes("page-layout-notices"), "additional page notices const headerLoadingMarkup = renderToStaticMarkup( - + Stable content @@ -79,7 +83,7 @@ assert(!headerLoadingMarkup.includes('aria-busy="true"'), "partial refresh does const delegatedHeaderMarkup = renderToStaticMarkup( - +

Contributed administration panel