From 7685a103e898d2a1e45f2da22557d7a3b9900467 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 18 Aug 2026 13:17:31 +0200 Subject: [PATCH] Centralize shared WebUI structural primitives --- docs/INTERFACE_PATTERN_MIGRATION.md | 27 ++-- webui/src/components/ActionToolbar.tsx | 6 +- webui/src/components/Card.tsx | 45 ++++-- webui/src/components/ContentGrid.tsx | 7 + webui/src/components/ContentSection.tsx | 37 +++++ webui/src/components/CountBadge.tsx | 29 ++++ webui/src/components/DefinitionNodeIcon.tsx | 12 ++ webui/src/components/DefinitionPalette.tsx | 53 +++++++ webui/src/components/FilterBar.tsx | 42 +++++ webui/src/components/FloatingStatus.tsx | 20 +++ webui/src/components/MetricCard.tsx | 43 ++++- webui/src/components/PageLayout.tsx | 20 ++- webui/src/components/PolicyTable.tsx | 7 +- .../components/ResourceAccessExplanation.tsx | 6 +- webui/src/components/SelectionList.tsx | 28 +++- webui/src/components/StatePanel.tsx | 55 +++++++ webui/src/components/WorkspaceFrame.tsx | 45 ++++++ webui/src/components/WorkspaceLayout.tsx | 4 + webui/src/features/settings/SettingsPage.tsx | 31 ++-- webui/src/index.ts | 26 +++- webui/src/layout/Titlebar.tsx | 5 +- webui/src/styles/components.css | 147 ++++++++++++++++-- webui/src/styles/forms.css | 1 + webui/src/styles/layout.css | 78 +++++++++- webui/tests/layout-primitives.test.tsx | 63 +++++++- webui/tests/page-layout.test.tsx | 11 ++ 26 files changed, 767 insertions(+), 81 deletions(-) create mode 100644 webui/src/components/ContentSection.tsx create mode 100644 webui/src/components/CountBadge.tsx create mode 100644 webui/src/components/DefinitionNodeIcon.tsx create mode 100644 webui/src/components/DefinitionPalette.tsx create mode 100644 webui/src/components/FilterBar.tsx create mode 100644 webui/src/components/FloatingStatus.tsx create mode 100644 webui/src/components/StatePanel.tsx create mode 100644 webui/src/components/WorkspaceFrame.tsx diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md index d1c5db2..479e4ee 100644 --- a/docs/INTERFACE_PATTERN_MIGRATION.md +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -13,14 +13,18 @@ domain modules own their compositions. | Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` | | Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` | | Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract | -| Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Standalone, workspace, and embedded modes make inset and scroll ownership explicit; sticky heading, rich descriptions, route actions, notices, loading, narrow-layout collapse, and contextual-help identity are centralized; `AdminPageLayout` composes the same contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core fallback dashboard, Dashboard module, Ops module, Campaign pages, and `check-shared-webui-layouts.py` | -| 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`, Campaign workspace, Campaign module workspace, Templates workspace, Approvals list/detail workspace, and `check-shared-webui-layouts.py` | +| Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Standalone, workspace, and embedded modes make inset and scroll ownership explicit; sticky heading, rich descriptions, route actions, notices, loading, narrow-layout collapse, and contextual-help identity are centralized; composite administration workspaces may delegate the visible heading to their contributed panel while retaining the same frame; `AdminPageLayout` composes the contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core Settings, Access administration, Docs, Mail bounce processing, Dashboard, Ops, Campaign, and `check-shared-webui-layouts.py` | +| 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 | +| 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 | | 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 | | Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build | ## Boundary @@ -35,13 +39,12 @@ Raw JSON remains permitted only for diagnostics, expert inspection, interchange, or conflict evidence. It is not a primary Core configuration editor. -Raw page-frame and full-canvas workspace debt is registered in the meta -repository and may only decrease. New headed pages use `PageLayout`; pages -inside `WorkspaceLayout` use its `workspace` mode so the pane owns scrolling -while the page retains the standard inset. Module CSS continues to own domain -content layout, never the shared page or workspace frame. Raw toolbar tags, -retired generic grid and description-list classes, raw dialog-form wrappers, -and module-local copies of the shared toolbar/grid/form-section/dialog-anatomy -components 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. +New headed pages use `PageLayout`; full-canvas modules use `WorkspaceFrame` +and, where applicable, `WorkspaceLayout`, so Core owns the frame and pane +scrolling. Module CSS continues to own unequal domain content layout, never the +shared page, workspace, toolbar, state, list, filter, metric, section, or graph +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. diff --git a/webui/src/components/ActionToolbar.tsx b/webui/src/components/ActionToolbar.tsx index a273a87..25af1c1 100644 --- a/webui/src/components/ActionToolbar.tsx +++ b/webui/src/components/ActionToolbar.tsx @@ -3,13 +3,15 @@ import { usePlatformLanguage } from "../i18n/LanguageContext"; import type { PlatformInterfaceIdentityProps } from "../types"; export type ActionToolbarDensity = "compact" | "default"; -export type ActionToolbarSurface = "plain" | "subtle"; +export type ActionToolbarSurface = "plain" | "subtle" | "panel-header" | "section-header"; export type ActionToolbarWrap = "responsive" | "always" | "never"; +export type ActionToolbarJustify = "start" | "between" | "end"; export type ActionToolbarProps = PlatformInterfaceIdentityProps & Omit, "children"> & { children: ReactNode; label?: string; density?: ActionToolbarDensity; + justify?: ActionToolbarJustify; surface?: ActionToolbarSurface; wrap?: ActionToolbarWrap; }; @@ -18,6 +20,7 @@ export default function ActionToolbar({ children, label, density = "default", + justify = "start", surface = "plain", wrap = "responsive", className = "", @@ -38,6 +41,7 @@ export default function ActionToolbar({ className={[ "action-toolbar", `action-toolbar-density-${density}`, + `action-toolbar-justify-${justify}`, `action-toolbar-surface-${surface}`, `action-toolbar-wrap-${wrap}`, className diff --git a/webui/src/components/Card.tsx b/webui/src/components/Card.tsx index 51e9b08..cddbd24 100644 --- a/webui/src/components/Card.tsx +++ b/webui/src/components/Card.tsx @@ -1,15 +1,20 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, type HTMLAttributes, type ReactNode } from "react"; import { ChevronDown } from "lucide-react"; import { usePlatformLanguage } from "../i18n/LanguageContext"; import type { PlatformInterfaceIdentityProps } from "../types"; -type CardProps = PlatformInterfaceIdentityProps & { +export type CardProps = PlatformInterfaceIdentityProps & Omit, "children" | "title"> & { title?: ReactNode; children: ReactNode; actions?: ReactNode; + afterBody?: ReactNode; collapsible?: boolean; collapseKey?: string; persistCollapse?: boolean; + as?: "section" | "article"; + headerClassName?: string; + bodyClassName?: string; + actionsClassName?: string; }; function resolveCollapseStorageKey(collapsible: boolean, persistCollapse: boolean, collapseKey: string | undefined, title: ReactNode): string | null { @@ -39,13 +44,32 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void // localStorage may be unavailable in private or restricted contexts. }} -export default function Card({ title, children, actions, collapsible = false, collapseKey, persistCollapse = true, interfaceId, helpContextId, helpModuleId, helpTopicId }: CardProps) { +export default function Card({ + title, + children, + actions, + afterBody, + collapsible = false, + collapseKey, + persistCollapse = true, + as = "section", + className = "", + headerClassName = "", + bodyClassName = "", + actionsClassName = "", + interfaceId, + helpContextId, + helpModuleId, + helpTopicId, + ...props +}: CardProps) { const { translateText } = usePlatformLanguage(); + const Element = as; const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title); const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) })); const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey); const hasHeader = Boolean(title || actions || collapsible); - const body =
{children}
; + const body =
{children}
; const shouldRenderBody = !collapsible || !collapsed; const collapseLabel = translateText(collapsed ? "i18n:govoplan-core.show_content.0528d8d2" : "i18n:govoplan-core.show_header_only.24afefca"); @@ -60,8 +84,9 @@ export default function Card({ title, children, actions, collapsible = false, co } return ( -
{hasHeader && -
+
{title && (typeof title === "string" ?

{translateText(title)}

:
{title}
)} {(actions || collapsible) && -
+
{actions} {collapsible &&
); + {shouldRenderBody && (collapsible ?
{body}{afterBody}
: <>{body}{afterBody})} + ); } diff --git a/webui/src/components/ContentGrid.tsx b/webui/src/components/ContentGrid.tsx index b422be7..a7fed2c 100644 --- a/webui/src/components/ContentGrid.tsx +++ b/webui/src/components/ContentGrid.tsx @@ -2,12 +2,14 @@ import type { FormHTMLAttributes, HTMLAttributes, ReactNode } from "react"; export type ContentGridColumns = 1 | 2 | 3 | 4; export type ContentGridGap = "compact" | "small" | "default" | "loose"; +export type ContentGridSpacing = "none" | "block"; export type ContentGridCollapseAt = "narrow" | "workspace" | "standard" | "wide" | "never"; export type ContentGridProps = HTMLAttributes & { children: ReactNode; columns?: ContentGridColumns; gap?: ContentGridGap; + spacing?: ContentGridSpacing; collapseAt?: ContentGridCollapseAt; align?: "start" | "stretch"; }; @@ -16,6 +18,7 @@ export default function ContentGrid({ children, columns = 2, gap = "default", + spacing = "none", collapseAt = "workspace", align = "start", className = "", @@ -28,6 +31,7 @@ export default function ContentGrid({ "content-grid-layout", `content-grid-columns-${columns}`, `content-grid-gap-${gap}`, + `content-grid-spacing-${spacing}`, `content-grid-collapse-${collapseAt}`, `content-grid-align-${align}`, className @@ -66,6 +70,7 @@ export type FormLayoutProps = FormHTMLAttributes & { children: ReactNode; columns?: ContentGridColumns; gap?: ContentGridGap; + spacing?: ContentGridSpacing; collapseAt?: ContentGridCollapseAt; }; @@ -73,6 +78,7 @@ export function FormLayout({ children, columns = 1, gap = "default", + spacing = "none", collapseAt = "standard", className = "", ...props @@ -85,6 +91,7 @@ export function FormLayout({ "form-grid-layout", `content-grid-columns-${columns}`, `content-grid-gap-${gap}`, + `content-grid-spacing-${spacing}`, `content-grid-collapse-${collapseAt}`, "content-grid-align-start", className diff --git a/webui/src/components/ContentSection.tsx b/webui/src/components/ContentSection.tsx new file mode 100644 index 0000000..01f08a3 --- /dev/null +++ b/webui/src/components/ContentSection.tsx @@ -0,0 +1,37 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +export type ContentSectionProps = Omit, "children"> & { + as?: "section" | "article"; + children: ReactNode; + density?: "none" | "compact" | "default"; + layout?: "flow" | "stack"; + spacing?: "none" | "block"; + surface?: "panel" | "subtle"; +}; + +export default function ContentSection({ + as: Component = "section", + children, + density = "none", + layout = "flow", + spacing = "block", + surface = "panel", + className = "", + ...props +}: ContentSectionProps) { + return ( + + {children} + + ); +} diff --git a/webui/src/components/CountBadge.tsx b/webui/src/components/CountBadge.tsx new file mode 100644 index 0000000..5daf575 --- /dev/null +++ b/webui/src/components/CountBadge.tsx @@ -0,0 +1,29 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +export type CountBadgeProps = HTMLAttributes & { + children: ReactNode; + tone?: "accent" | "neutral" | "danger"; + size?: "compact" | "default"; +}; + +export default function CountBadge({ + children, + tone = "accent", + size = "default", + className = "", + ...props +}: CountBadgeProps) { + return ( + + {children} + + ); +} diff --git a/webui/src/components/DefinitionNodeIcon.tsx b/webui/src/components/DefinitionNodeIcon.tsx new file mode 100644 index 0000000..31f85ed --- /dev/null +++ b/webui/src/components/DefinitionNodeIcon.tsx @@ -0,0 +1,12 @@ +import type { HTMLAttributes } from "react"; + +export type DefinitionNodeIconProps = HTMLAttributes; + +export default function DefinitionNodeIcon({ className = "", ...props }: DefinitionNodeIconProps) { + return ( + + ); +} diff --git a/webui/src/components/DefinitionPalette.tsx b/webui/src/components/DefinitionPalette.tsx new file mode 100644 index 0000000..2198444 --- /dev/null +++ b/webui/src/components/DefinitionPalette.tsx @@ -0,0 +1,53 @@ +import { Plus } from "lucide-react"; +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; +import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext"; +import ActionToolbar from "./ActionToolbar"; + +export type DefinitionPaletteProps = Omit, "children"> & { + label: ReactNode; + description?: ReactNode; + actions?: ReactNode; + children: ReactNode; +}; + +export default function DefinitionPalette({ label, description, actions, children, className = "", ...props }: DefinitionPaletteProps) { + const { translateText } = usePlatformLanguage(); + return ( + + ); +} + +export function DefinitionPaletteGroup({ label, children, className = "", ...props }: Omit, "title"> & { label: ReactNode }) { + const { translateText } = usePlatformLanguage(); + return ( +
+

{translateReactNode(label, translateText)}

+ {children} +
+ ); +} + +export type DefinitionPaletteItemProps = Omit, "children"> & { + icon: ReactNode; + label: ReactNode; +}; + +export function DefinitionPaletteItem({ icon, label, className = "", type = "button", ...props }: DefinitionPaletteItemProps) { + const { translateText } = usePlatformLanguage(); + return ( + + ); +} diff --git a/webui/src/components/FilterBar.tsx b/webui/src/components/FilterBar.tsx new file mode 100644 index 0000000..add11cc --- /dev/null +++ b/webui/src/components/FilterBar.tsx @@ -0,0 +1,42 @@ +import type { HTMLAttributes, ReactNode } from "react"; + +export type FilterBarLayout = "row" | "grid" | "stack"; +export type FilterBarSurface = "plain" | "panel" | "control"; +export type FilterBarWidth = "auto" | "compact" | "default" | "wide" | "full"; + +export type FilterBarProps = HTMLAttributes & { + children: ReactNode; + as?: "div" | "form"; + layout?: FilterBarLayout; + surface?: FilterBarSurface; + width?: FilterBarWidth; + wrap?: "responsive" | "always" | "never"; +}; + +export default function FilterBar({ + children, + as = "div", + layout = "row", + surface = "plain", + width = "full", + wrap = "responsive", + className = "", + ...props +}: FilterBarProps) { + const Element = as; + return ( + + {children} + + ); +} diff --git a/webui/src/components/FloatingStatus.tsx b/webui/src/components/FloatingStatus.tsx new file mode 100644 index 0000000..ed46bf9 --- /dev/null +++ b/webui/src/components/FloatingStatus.tsx @@ -0,0 +1,20 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext"; + +export type FloatingStatusProps = HTMLAttributes & { + children: ReactNode; +}; + +export default function FloatingStatus({ children, className = "", role = "status", ...props }: FloatingStatusProps) { + const { translateText } = usePlatformLanguage(); + + return ( +
+ {translateReactNode(children, translateText)} +
+ ); +} diff --git a/webui/src/components/MetricCard.tsx b/webui/src/components/MetricCard.tsx index 870eebc..873c0d6 100644 --- a/webui/src/components/MetricCard.tsx +++ b/webui/src/components/MetricCard.tsx @@ -1,14 +1,43 @@ -import { usePlatformLanguage } from "../i18n/LanguageContext"; +import type { HTMLAttributes, ReactNode } from "react"; +import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext"; -export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) { +export type MetricCardProps = HTMLAttributes & { + label: ReactNode; + value: ReactNode; + tone?: "neutral" | "good" | "warning" | "danger" | "info"; + detail?: ReactNode; + valueTitle?: string; + density?: "compact" | "default"; + surface?: "card" | "subtle" | "flat"; +}; + +export default function MetricCard({ + label, + value, + tone = "neutral", + detail, + valueTitle, + density = "default", + surface = "card", + className = "", + ...props +}: MetricCardProps) { const { translateText } = usePlatformLanguage(); - const renderedValue = typeof value === "string" ? translateText(value) : value; + const renderedLabel = translateReactNode(label, translateText); + const renderedValue = translateReactNode(value, translateText); + const renderedDetail = translateReactNode(detail, translateText); return ( -
-
{translateText(label)}
-
{renderedValue}
- {detail &&
{translateText(detail)}
} +
+
{renderedLabel}
+
{renderedValue}
+ {renderedDetail ?
{renderedDetail}
: null}
); } diff --git a/webui/src/components/PageLayout.tsx b/webui/src/components/PageLayout.tsx index 764ae87..4db1dff 100644 --- a/webui/src/components/PageLayout.tsx +++ b/webui/src/components/PageLayout.tsx @@ -61,6 +61,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & { mode?: PageLayoutMode; scrollable?: boolean; stickyHeader?: boolean; + showHeader?: boolean; documentationType?: "user" | "admin"; className?: string; viewportClassName?: string; @@ -82,6 +83,7 @@ export default function PageLayout({ mode = "standalone", scrollable, stickyHeader = true, + showHeader = true, documentationType = "user", className = "", viewportClassName = "", @@ -112,14 +114,16 @@ export default function PageLayout({ data-help-documentation-type={documentationType} data-help-key={typeof title === "string" ? title : undefined} > - + {showHeader && ( + + )} {error && {error}} {success && {success}} {notices &&
{notices}
} diff --git a/webui/src/components/PolicyTable.tsx b/webui/src/components/PolicyTable.tsx index 853a502..8ac7f17 100644 --- a/webui/src/components/PolicyTable.tsx +++ b/webui/src/components/PolicyTable.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import FieldLabel from "./help/FieldLabel"; +import ActionToolbar from "./ActionToolbar"; type PolicySectionProps = { title?: ReactNode; @@ -44,15 +45,15 @@ export function PolicySection({ actions, children, className = "", - headingClassName = "subsection-heading split" + headingClassName = "" }: PolicySectionProps) { return (
{(title || summary || actions) && -
+ {title &&

{title}

} {actions ?? summary} -
+ } {children}
); diff --git a/webui/src/components/ResourceAccessExplanation.tsx b/webui/src/components/ResourceAccessExplanation.tsx index 2533321..d6e9341 100644 --- a/webui/src/components/ResourceAccessExplanation.tsx +++ b/webui/src/components/ResourceAccessExplanation.tsx @@ -1,4 +1,4 @@ -import { FormGrid } from "./ContentGrid"; +import ContentGrid, { FormGrid } from "./ContentGrid"; import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts"; export type ResourceAccessExplanationProps = { @@ -33,7 +33,7 @@ export default function ResourceAccessExplanation({ {explanation.provenance.length === 0 ? (

i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e

) : ( -
+ {explanation.provenance.map((item, index) => (
{resourceAccessProvenanceKindLabel(item.kind)} @@ -47,7 +47,7 @@ export default function ResourceAccessExplanation({ )}
))} -
+ )} ); diff --git a/webui/src/components/SelectionList.tsx b/webui/src/components/SelectionList.tsx index 0f0c10c..16ef8d4 100644 --- a/webui/src/components/SelectionList.tsx +++ b/webui/src/components/SelectionList.tsx @@ -4,6 +4,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext"; export type SelectionListProps = Omit, "children" | "role"> & { children: ReactNode; label?: string; + variant?: "plain" | "navigation"; }; export type SelectionListItemProps = Omit< @@ -19,10 +20,11 @@ export default function SelectionList({ children, className = "", label, + variant = "plain", ...props }: SelectionListProps) { const { translateText } = usePlatformLanguage(); - const rootClassName = ["selection-list", className].filter(Boolean).join(" "); + const rootClassName = ["selection-list", `selection-list-${variant}`, className].filter(Boolean).join(" "); return (
& { + title: ReactNode; + description?: ReactNode; + leading?: ReactNode; +}; + +export function SelectionListItemContent({ + title, + description, + leading, + className = "", + ...props +}: SelectionListItemContentProps) { + return ( + + {leading ? : null} + + {title} + {description ? {description} : null} + + + ); +} + export function SelectionListItem({ children, className = "", diff --git a/webui/src/components/StatePanel.tsx b/webui/src/components/StatePanel.tsx new file mode 100644 index 0000000..0ba0c98 --- /dev/null +++ b/webui/src/components/StatePanel.tsx @@ -0,0 +1,55 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext"; + +export type StatePanelSize = "inline" | "compact" | "default" | "fill"; +export type StatePanelSurface = "plain" | "subtle" | "dashed"; +export type StatePanelTone = "neutral" | "info" | "warning" | "danger"; + +export type StatePanelProps = Omit, "title"> & { + icon?: ReactNode; + title?: ReactNode; + description?: ReactNode; + actions?: ReactNode; + size?: StatePanelSize; + surface?: StatePanelSurface; + tone?: StatePanelTone; + align?: "start" | "center"; +}; + +export default function StatePanel({ + icon, + title, + description, + actions, + children, + size = "default", + surface = "plain", + tone = "neutral", + align = "center", + className = "", + ...props +}: StatePanelProps) { + const { translateText } = usePlatformLanguage(); + const translatedTitle = translateReactNode(title, translateText); + const translatedDescription = translateReactNode(description, translateText); + + return ( +
+ {icon ? : null} + {translatedTitle ?

{translatedTitle}

: null} + {translatedDescription ?
{translatedDescription}
: null} + {children ?
{children}
: null} + {actions ?
{actions}
: null} +
+ ); +} diff --git a/webui/src/components/WorkspaceFrame.tsx b/webui/src/components/WorkspaceFrame.tsx new file mode 100644 index 0000000..335bf85 --- /dev/null +++ b/webui/src/components/WorkspaceFrame.tsx @@ -0,0 +1,45 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import { usePlatformLanguage } from "../i18n/LanguageContext"; +import type { PlatformInterfaceIdentityProps } from "../types"; + +export type WorkspaceFrameProps = PlatformInterfaceIdentityProps & Omit, "children"> & { + children: ReactNode; + as?: "div" | "main" | "section"; + height?: "container" | "viewport"; + label?: string; + surface?: "plain" | "panel"; + documentationType?: "user" | "admin"; +}; + +export default function WorkspaceFrame({ + children, + as: Component = "div", + height = "container", + label, + surface = "panel", + documentationType = "user", + className = "", + interfaceId, + helpContextId, + helpModuleId, + helpTopicId, + ...props +}: WorkspaceFrameProps) { + const { translateText } = usePlatformLanguage(); + return ( + + {children} + + ); +} diff --git a/webui/src/components/WorkspaceLayout.tsx b/webui/src/components/WorkspaceLayout.tsx index 7d63982..c678277 100644 --- a/webui/src/components/WorkspaceLayout.tsx +++ b/webui/src/components/WorkspaceLayout.tsx @@ -4,6 +4,7 @@ import type { PlatformInterfaceIdentityProps } from "../types"; export type WorkspaceLayoutVariant = "navigation" | "split"; export type WorkspacePrimarySize = "compact" | "default" | "wide"; +export type WorkspaceLayoutSurface = "plain" | "contained"; export type WorkspaceLayoutProps = PlatformInterfaceIdentityProps & { primary: ReactNode; @@ -15,6 +16,7 @@ export type WorkspaceLayoutProps = PlatformInterfaceIdentityProps & { documentationType?: "user" | "admin"; primaryScrollable?: boolean; contentScrollable?: boolean; + surface?: WorkspaceLayoutSurface; className?: string; primaryClassName?: string; contentClassName?: string; @@ -30,6 +32,7 @@ export default function WorkspaceLayout({ documentationType = "user", primaryScrollable, contentScrollable = true, + surface = "plain", className = "", primaryClassName = "", contentClassName = "", @@ -48,6 +51,7 @@ export default function WorkspaceLayout({ "workspace-layout", `workspace-layout-${variant}`, `workspace-layout-primary-${primarySize}`, + `workspace-layout-surface-${surface}`, className ].filter(Boolean).join(" ")} data-help-scope="workspace" diff --git a/webui/src/features/settings/SettingsPage.tsx b/webui/src/features/settings/SettingsPage.tsx index 16a7bf5..a91c741 100644 --- a/webui/src/features/settings/SettingsPage.tsx +++ b/webui/src/features/settings/SettingsPage.tsx @@ -7,7 +7,7 @@ import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PasswordField from "../../components/PasswordField"; import Button from "../../components/Button"; -import PageTitle from "../../components/PageTitle"; +import PageLayout from "../../components/PageLayout"; import ToggleSwitch from "../../components/ToggleSwitch"; import { apiFetch } from "../../api/client"; import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth"; @@ -22,6 +22,7 @@ import { hasAnyScope, hasScope } from "../../utils/permissions"; import { usePlatformLanguage } from "../../i18n/LanguageContext"; import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager"; import DocumentationHelpLink from "../../components/help/DocumentationHelpLink"; +import WorkspaceLayout from "../../components/WorkspaceLayout"; type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string; @@ -306,17 +307,18 @@ export default function SettingsPage({ } return ( -
- -
-
-
-
- i18n:govoplan-core.settings.c7f73bb5 -

i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56

-
- -
+ } + primaryLabel="i18n:govoplan-core.settings.c7f73bb5" + contentLabel="i18n:govoplan-core.settings.c7f73bb5" + > + } + mode="workspace" + > {active === "profile" && @@ -551,9 +553,8 @@ export default function SettingsPage({ selectSection }) } -
-
-
); + + ); } diff --git a/webui/src/index.ts b/webui/src/index.ts index bd0a49e..0a71dc4 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -50,7 +50,7 @@ export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessB export { default as ActionBlockerHint } from "./components/ActionBlockerHint"; export type { ActionBlockerLabels, ActionBlockerReason } from "./components/ActionBlockerHint"; export { default as ActionToolbar, ToolbarGroup, ToolbarSpacer } from "./components/ActionToolbar"; -export type { ActionToolbarDensity, ActionToolbarProps, ActionToolbarSurface, ActionToolbarWrap, ToolbarGroupProps } from "./components/ActionToolbar"; +export type { ActionToolbarDensity, ActionToolbarJustify, ActionToolbarProps, ActionToolbarSurface, ActionToolbarWrap, ToolbarGroupProps } from "./components/ActionToolbar"; export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel"; export { default as AdminIconButton } from "./components/admin/AdminIconButton"; export type { AdminIconButtonProps } from "./components/admin/AdminIconButton"; @@ -61,6 +61,9 @@ export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components export { default as Button } from "./components/Button"; export type { ButtonProps } from "./components/Button"; export { default as Card } from "./components/Card"; +export type { CardProps } from "./components/Card"; +export { default as CountBadge } from "./components/CountBadge"; +export type { CountBadgeProps } from "./components/CountBadge"; export { default as ConfirmDialog } from "./components/ConfirmDialog"; export { default as ConcurrencyConflictDialog, @@ -91,6 +94,10 @@ export type { DialogBodyPadding, DialogProps, DialogSize, DialogVariant } from " export { DialogActions, DialogForm, DialogSection } from "./components/DialogAnatomy"; export type { DialogActionAlignment, DialogActionsProps, DialogFormProps, DialogSectionProps } from "./components/DialogAnatomy"; export { default as DescriptionList, DescriptionItem } from "./components/DescriptionList"; +export { default as DefinitionPalette, DefinitionPaletteGroup, DefinitionPaletteItem } from "./components/DefinitionPalette"; +export type { DefinitionPaletteItemProps, DefinitionPaletteProps } from "./components/DefinitionPalette"; +export { default as DefinitionNodeIcon } from "./components/DefinitionNodeIcon"; +export type { DefinitionNodeIconProps } from "./components/DefinitionNodeIcon"; export type { DescriptionItemProps, DescriptionListCollapseAt, DescriptionListColumns, DescriptionListDensity, DescriptionListProps, DescriptionListTermWidth, DescriptionListVariant } from "./components/DescriptionList"; export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip"; export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip"; @@ -102,9 +109,15 @@ export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./compone export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock"; export { default as FileDropZone } from "./components/FileDropZone"; export type { FileDropZoneProps } from "./components/FileDropZone"; +export { default as FilterBar } from "./components/FilterBar"; +export type { FilterBarLayout, FilterBarProps, FilterBarSurface, FilterBarWidth } from "./components/FilterBar"; +export { default as FloatingStatus } from "./components/FloatingStatus"; +export type { FloatingStatusProps } from "./components/FloatingStatus"; export { default as FormField } from "./components/FormField"; export { default as ContentGrid, FormGrid, FormLayout, GridItem } from "./components/ContentGrid"; export type { ContentGridCollapseAt, ContentGridColumns, ContentGridGap, ContentGridProps, FormGridProps, FormLayoutProps, GridItemProps } from "./components/ContentGrid"; +export { default as ContentSection } from "./components/ContentSection"; +export type { ContentSectionProps } from "./components/ContentSection"; export { default as FormSection } from "./components/FormSection"; export type { FormSectionProps, FormSectionVariant } from "./components/FormSection"; export { default as GuidedConfigDialog } from "./components/GuidedConfigDialog"; @@ -120,6 +133,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 { default as MetricGrid } from "./components/MetricGrid"; export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid"; export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel"; @@ -130,7 +144,9 @@ export type { PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./compone export { default as PageScrollViewport } from "./components/PageScrollViewport"; export type { PageScrollViewportProps } from "./components/PageScrollViewport"; export { default as WorkspaceLayout } from "./components/WorkspaceLayout"; -export type { WorkspaceLayoutProps, WorkspaceLayoutVariant, WorkspacePrimarySize } from "./components/WorkspaceLayout"; +export type { WorkspaceLayoutProps, WorkspaceLayoutSurface, WorkspaceLayoutVariant, WorkspacePrimarySize } from "./components/WorkspaceLayout"; +export { default as WorkspaceFrame } from "./components/WorkspaceFrame"; +export type { WorkspaceFrameProps } from "./components/WorkspaceFrame"; export { default as PasswordField } from "./components/PasswordField"; export { default as PasswordGeneratorDialog } from "./components/PasswordGeneratorDialog"; export { default as PeoplePicker } from "./components/people/PeoplePicker"; @@ -180,8 +196,10 @@ export { DashboardWidgetList, useDashboardWidgetData } from "./components/Dashbo export type { DashboardWidgetDataState, DashboardWidgetListItem } from "./components/DashboardWidgetContent"; export { default as SegmentedControl } from "./components/SegmentedControl"; export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl"; -export { default as SelectionList, SelectionListItem } from "./components/SelectionList"; -export type { SelectionListItemProps, SelectionListProps } from "./components/SelectionList"; +export { default as SelectionList, SelectionListItem, SelectionListItemContent } from "./components/SelectionList"; +export type { SelectionListItemContentProps, SelectionListItemProps, SelectionListProps } from "./components/SelectionList"; +export { default as StatePanel } from "./components/StatePanel"; +export type { StatePanelProps, StatePanelSize, StatePanelSurface, StatePanelTone } from "./components/StatePanel"; export { default as StatusBadge } from "./components/StatusBadge"; export { default as StageRail } from "./components/StageRail"; export type { diff --git a/webui/src/layout/Titlebar.tsx b/webui/src/layout/Titlebar.tsx index 31b90e4..4ce3838 100644 --- a/webui/src/layout/Titlebar.tsx +++ b/webui/src/layout/Titlebar.tsx @@ -6,6 +6,7 @@ import LanguageMenu from "./LanguageMenu"; import TemporalDataMenu from "./TemporalDataMenu"; import LoginModal from "../features/auth/LoginModal"; import DismissibleAlert from "../components/DismissibleAlert"; +import CountBadge from "../components/CountBadge"; import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard"; import { useSharedNotificationSummary } from "../api/notificationSummary"; import { logout, switchTenant } from "../api/auth"; @@ -230,9 +231,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode } diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index fa43112..d297ab3 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -1046,16 +1046,6 @@ height: 17px; } -.admin-assignment-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.admin-assignment-grid { - display: grid; - gap: 18px; - margin-top: 18px; -} - .admin-selection-list { display: grid; gap: 6px; @@ -1093,7 +1083,6 @@ } @media (max-width: 900px) { - .admin-assignment-grid, .admin-permission-groups { grid-template-columns: 1fr; } @@ -2986,6 +2975,142 @@ min-width: 0; } +.selection-list-navigation { + height: 100%; + min-height: 0; + overflow: auto; + padding: 6px; +} + +.selection-list-navigation > .selection-list-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 56px; + border: 0; + padding: 8px 9px; +} + +.selection-list-navigation > .selection-list-item:hover:not(:disabled), +.selection-list-navigation > .selection-list-item:focus-visible { + background: var(--primary-soft); + outline: none; +} + +.selection-list-navigation > .selection-list-item.is-selected { + border-color: transparent; + background: var(--primary-soft-strong); + box-shadow: inset 3px 0 0 var(--accent); +} + +.selection-list-item-content { + display: grid; + min-width: 0; + gap: 4px; +} + +.selection-list-item-content.has-leading { + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 9px; +} + +.selection-list-item-leading { + display: grid; + color: var(--muted); + place-items: center; +} + +.selection-list-item-copy { min-width: 0; } + +.selection-list-item-copy > strong, +.selection-list-item-copy > small { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selection-list-item-copy > strong { + color: var(--text-strong); + font-size: 13px; +} + +.selection-list-item-copy > small { + color: var(--muted); + font-size: 11px; +} + +.count-badge { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-width: 21px; + height: 21px; + padding: 0 6px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.count-badge-compact { min-width: 18px; height: 18px; padding-inline: 5px; font-size: 10px; } +.count-badge-accent { background: var(--accent); color: var(--on-accent); } +.count-badge-neutral { border: var(--border-line); background: var(--surface-subtle); color: var(--text); } +.count-badge-danger { background: var(--red); color: var(--on-accent); } + +.definition-palette { min-width: 0; min-height: 0; overflow: hidden; border-right: var(--border-line); background: var(--panel-soft); } +.definition-palette-heading-copy { min-width: 0; } +.definition-palette-heading-copy > strong, +.definition-palette-heading-copy > small { display: block; } +.definition-palette-heading-copy > strong { color: var(--text-strong); font-size: 13px; } +.definition-palette-heading-copy > small { overflow: hidden; margin-top: 2px; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.definition-palette-items { display: grid; grid-auto-rows: max-content; align-content: start; height: calc(100% - 44px); gap: 4px; overflow-x: hidden; overflow-y: auto; padding: 8px; scrollbar-gutter: stable; } +.definition-palette-group { display: grid; grid-auto-rows: max-content; align-content: start; gap: 2px; } +.definition-palette-group + .definition-palette-group { margin-top: 6px; padding-top: 8px; border-top: var(--border-line); } +.definition-palette-group h3 { margin: 0; color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: 0; padding: 3px 8px; text-transform: uppercase; } +.definition-palette-item { display: grid; grid-template-columns: 18px minmax(0, 1fr) 14px; align-items: center; gap: 7px; min-height: 38px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text); cursor: grab; font: inherit; font-size: 12px; padding: 7px 8px; text-align: left; } +.definition-palette-item:hover:not(:disabled), +.definition-palette-item:focus-visible:not(:disabled) { background: var(--primary-soft); color: var(--text-strong); outline: none; } +.definition-palette-item:active:not(:disabled) { cursor: grabbing; } +.definition-palette-item:disabled { cursor: default; opacity: .42; } +.definition-palette-item-icon { display: grid; place-items: center; } +.definition-palette-item-add { color: var(--accent); } + +.definition-graph-canvas { position: absolute; inset: 0; } +.definition-editor-surface { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: var(--bg); } +.definition-graph-canvas .react-flow { background: var(--bg); } +.definition-graph-canvas .react-flow__background { color: var(--line-dark); } +.definition-graph-canvas .react-flow__controls, +.definition-graph-canvas .react-flow__minimap { overflow: hidden; border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel); box-shadow: var(--shadow-xs); } +.definition-graph-canvas .react-flow__controls-button { border-bottom: var(--border-line); background: var(--panel); color: var(--text); } +.definition-graph-canvas .react-flow__minimap-mask { fill: color-mix(in srgb, var(--bg) 76%, transparent); } +.definition-graph-canvas .react-flow__edge-path { stroke: var(--line-dark); stroke-width: 2; } +.definition-graph-canvas .react-flow__edge.selected .react-flow__edge-path, +.definition-graph-canvas .react-flow__edge:hover .react-flow__edge-path { stroke: var(--accent); stroke-width: 3; } +.definition-graph-canvas-empty { position: absolute; inset: 0; pointer-events: none; } +.definition-node-icon { display: grid; width: 28px; height: 28px; flex: 0 0 28px; place-items: center; border-radius: 5px; background: var(--panel-soft); color: var(--text-strong); } +.definition-node-handle { width: 13px; height: 13px; border: 3px solid var(--panel); background: var(--line-dark); } +.definition-node-handle:hover, +.definition-node-handle.connectingto, +.definition-node-handle.valid { width: 17px; height: 17px; background: var(--accent); } +.floating-status { position: absolute; z-index: 20; right: 12px; bottom: 10px; border: var(--border-line-dark); border-radius: var(--radius-sm); background: var(--panel); box-shadow: var(--shadow-popover); color: var(--text-strong); font-size: 12px; padding: 7px 10px; } + +@media (max-width: 680px) { + .definition-palette { border-right: 0; border-bottom: var(--border-line); } + .definition-palette-header { display: none; } + .definition-palette-items { display: flex; overflow-x: auto; } + .definition-palette-group { display: flex; flex: 0 0 auto; } + .definition-palette-group + .definition-palette-group { margin-top: 0; padding-top: 0; padding-left: 6px; border-top: 0; border-left: var(--border-line); } + .definition-palette-group h3 { display: none; } + .definition-palette-item { min-width: 128px; } +} + :where(.selection-list-item) { appearance: none; box-sizing: border-box; diff --git a/webui/src/styles/forms.css b/webui/src/styles/forms.css index 9fc01dc..65826a3 100644 --- a/webui/src/styles/forms.css +++ b/webui/src/styles/forms.css @@ -7,6 +7,7 @@ .content-grid-gap-small { gap: 14px; } .content-grid-gap-default { gap: 18px; } .content-grid-gap-loose { gap: 26px; } +.content-grid-spacing-block { margin-block: 18px; } .content-grid-align-start { align-items: start; } .content-grid-align-stretch { align-items: stretch; } .content-grid-item { min-width: 0; } diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index b458a24..16a1a60 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -51,7 +51,7 @@ .titlebar-icon-link.is-context-active { background: var(--accent-hover-bg); color: var(--accent); } .titlebar-icon-link.is-context-active:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); } .titlebar-notification-button { position: relative; } -.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); } +.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; border: 2px solid var(--titlebar-bg); transform: translate(35%, -35%); } .account-pill { color: var(--text); } .maintenance-topbar-link, .backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); min-height: 32px; max-width: min(560px, calc(100vw - 24px)); box-sizing: border-box; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; padding: 0 14px; overflow: hidden; font: inherit; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; } @@ -83,11 +83,17 @@ .action-toolbar { min-width: 0; display: flex; align-items: center; gap: 10px; } .action-toolbar-density-compact { gap: 7px; } .action-toolbar-surface-subtle { padding: 10px 12px; border: var(--border-line); border-radius: var(--radius); background: var(--panel-soft); } +.action-toolbar-surface-panel-header { min-height: 54px; flex: 0 0 auto; justify-content: space-between; padding: 9px 12px; border-bottom: var(--border-line); background: var(--panel-header); } +.action-toolbar-surface-section-header { min-height: 44px; flex: 0 0 auto; justify-content: space-between; padding: 7px 10px; border-bottom: var(--border-line); background: var(--panel-header); } +.action-toolbar-surface-section-header > h2, +.action-toolbar-surface-section-header > h3 { margin: 0; color: var(--text-strong); font-size: 13px; } .action-toolbar-wrap-always { flex-wrap: wrap; } .action-toolbar-wrap-never { flex-wrap: nowrap; overflow-x: auto; } .action-toolbar-group { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } .action-toolbar-group-center { justify-content: center; } .action-toolbar-group-end { justify-content: flex-end; margin-inline-start: auto; } +.action-toolbar-justify-between { justify-content: space-between; } +.action-toolbar-justify-end { justify-content: flex-end; } .action-toolbar-group-grow { flex: 1 1 auto; } .action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; } .app-content { min-height: 0; overflow: hidden; } @@ -102,6 +108,16 @@ .workspace-layout-split { grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); } .workspace-layout-split.workspace-layout-primary-compact { grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); } .workspace-layout-split.workspace-layout-primary-wide { grid-template-columns: minmax(340px, 440px) minmax(0, 1fr); } +.workspace-layout-surface-contained { width: 100%; min-width: 0; overflow: hidden; border: var(--border-line); background: var(--panel); } +.workspace-layout-surface-contained > .workspace-layout-primary { display: flex; flex-direction: column; overflow: hidden; border-right: var(--border-line); background: var(--panel-soft); } +.workspace-layout-surface-contained > .workspace-layout-content { position: relative; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); } +.workspace-frame { box-sizing: border-box; display: flex; width: 100%; height: 100%; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; } +.workspace-frame *, +.workspace-frame *::before, +.workspace-frame *::after { box-sizing: border-box; } +.workspace-frame-height-viewport { position: relative; height: calc(100vh - 115px); height: calc(100dvh - 115px); padding: 0; } +.workspace-frame-surface-panel { background: var(--panel); } +.workspace-frame-surface-plain { background: var(--bg); } .section-sidebar { background: var(--sidebar-bg); border-right: var(--border-line-dark); padding: 18px 0; border-left: 3px solid var(--sidebar-border); box-shadow: var(--shadow-chrome); z-index: 80; overflow-y: scroll; } .section-title { font-size: 12px; font-weight: 800; color: var(--muted); padding: 0 22px 14px; letter-spacing: .06em; } .section-title-lower { margin-top: 28px; } @@ -164,6 +180,14 @@ .ui-no-sticky-section-sidebars .workspace-content { overflow: visible; } .ui-no-sticky-section-sidebars .workspace-layout-primary { overflow: visible; } .content-pad { min-width: 0; max-width: 100%; box-sizing: border-box; padding: 28px 34px; } +.content-section { min-width: 0; border: var(--border-line); } +.content-section-layout-stack { display: grid; gap: 6px; } +.content-section-density-compact { padding: 10px; border-radius: var(--radius-sm); } +.content-section-density-default { padding: 18px; border-radius: var(--radius); } +.content-section-spacing-block { margin-bottom: 14px; } +.content-section-spacing-none { margin-bottom: 0; } +.content-section-surface-panel { background: var(--panel); } +.content-section-surface-subtle { background: var(--panel-soft); } .page-heading { margin-bottom: 22px; } .page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; } .page-heading p { margin: 6px 0 0; color: var(--muted); } @@ -239,6 +263,12 @@ .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-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; } +.metric-card-density-compact .metric-detail { font-size: 12px; } +.metric-card-surface-subtle { background: var(--panel-soft); } +.metric-card-surface-flat { border-radius: 0; box-shadow: none; } .metric-good { border-top-color: var(--green); } .metric-warning { border-top-color: var(--amber); } .metric-danger { border-top-color: var(--red); } .metric-info { border-top-color: var(--blue); } .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; } @@ -297,6 +327,7 @@ .workspace { grid-template-columns: 1fr; } .workspace-layout-navigation > .workspace-layout-primary { display: none; } .workspace-layout-split { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } + .workspace-layout-surface-contained > .workspace-layout-primary { border-right: 0; border-bottom: var(--border-line); } .section-sidebar { display: none; } .wizard-card { grid-template-columns: 1fr; } .metric-group-collapse-workspace { grid-template-columns: minmax(0, 1fr); } @@ -319,6 +350,9 @@ .action-toolbar-wrap-responsive { align-items: stretch; flex-wrap: wrap; } .action-toolbar-wrap-responsive > .action-toolbar-group { flex: 1 1 100%; } .action-toolbar-wrap-responsive > .action-toolbar-group-end { margin-inline-start: 0; justify-content: flex-start; } + .filter-bar-wrap-responsive.filter-bar-layout-row { align-items: stretch; flex-wrap: wrap; } + .filter-bar-wrap-responsive.filter-bar-layout-row > input:not([type="checkbox"]):not([type="radio"]), + .filter-bar-wrap-responsive.filter-bar-layout-row > select { flex-basis: 100%; } } @@ -359,7 +393,46 @@ .empty-state { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; } .empty-state h2 { margin: 0; color: var(--text-strong); } .empty-state p { max-width: 520px; margin: 8px auto 18px; color: var(--muted); } -.docs-workspace { grid-template-columns: 292px minmax(0, 1fr); background: var(--panel); } +.state-panel { box-sizing: border-box; min-width: 0; display: grid; justify-items: center; align-content: center; gap: 8px; color: var(--muted); text-align: center; } +.state-panel-size-inline { min-height: 0; padding: 8px 10px; } +.state-panel-size-compact { min-height: 110px; padding: 18px; } +.state-panel-size-default { min-height: 180px; padding: 28px; } +.state-panel-size-fill { width: 100%; height: 100%; min-height: 100%; padding: 32px; } +.state-panel-surface-subtle { border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel-soft); } +.state-panel-surface-dashed { border: 1px dashed var(--line-dark); border-radius: var(--radius-sm); background: var(--panel-soft); } +.state-panel-align-start { justify-items: start; text-align: start; } +.state-panel-tone-info { color: var(--info-text); } +.state-panel-tone-warning { color: var(--warning-text); } +.state-panel-tone-danger { color: var(--danger-text); } +.state-panel-icon { display: grid; place-items: center; color: currentColor; } +.state-panel-title { margin: 0; color: var(--text-strong); font-size: 18px; letter-spacing: 0; } +.state-panel-description, +.state-panel-content { max-width: 520px; line-height: 1.45; } +.state-panel-description > :first-child, +.state-panel-content > :first-child { margin-top: 0; } +.state-panel-description > :last-child, +.state-panel-content > :last-child { margin-bottom: 0; } +.state-panel-actions { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px; margin-top: 4px; } +.state-panel-align-start .state-panel-actions { justify-content: flex-start; } +.filter-bar { box-sizing: border-box; min-width: 0; gap: 8px; } +.filter-bar-layout-row { display: flex; align-items: center; } +.filter-bar-layout-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); align-items: center; } +.filter-bar-layout-stack { display: grid; } +.filter-bar-width-auto { width: auto; } +.filter-bar-width-compact { width: min(420px, 100%); } +.filter-bar-width-default { width: min(560px, 100%); } +.filter-bar-width-wide { width: min(680px, 100%); } +.filter-bar-width-full { width: 100%; } +.filter-bar-surface-panel { padding: 9px 10px; border-bottom: var(--border-line); background: var(--panel); } +.filter-bar-surface-control { min-height: 36px; padding: 5px 9px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); } +.filter-bar-surface-control:focus-within { border-color: var(--accent); box-shadow: var(--accent-ring-soft); } +.filter-bar-layout-row > input:not([type="checkbox"]):not([type="radio"]), +.filter-bar-layout-row > select { min-width: 120px; flex: 1 1 auto; } +.filter-bar-surface-control > input:not([type="checkbox"]):not([type="radio"]), +.filter-bar-surface-control > select { min-width: 0; min-height: 0; padding-block: 1px; border: 0; background: transparent; box-shadow: none; } +.filter-bar-wrap-always { flex-wrap: wrap; } +.filter-bar-wrap-never { flex-wrap: nowrap; overflow-x: auto; } +.docs-workspace { background: var(--panel); } .docs-workspace-content { background: var(--panel); } .docs-outline { padding-top: 22px; } .docs-sidebar-header { padding: 0 16px 16px; } @@ -414,7 +487,6 @@ margin-left: 0; } .summary-grid { grid-template-columns: 1fr; } - .docs-workspace { grid-template-columns: 1fr; } .docs-outline { display: none; } .docs-content { grid-template-columns: 1fr; } .docs-page-outline-box { display: none; } diff --git a/webui/tests/layout-primitives.test.tsx b/webui/tests/layout-primitives.test.tsx index 77634ef..2876927 100644 --- a/webui/tests/layout-primitives.test.tsx +++ b/webui/tests/layout-primitives.test.tsx @@ -5,16 +5,25 @@ function assert(condition: unknown, message = "assertion failed"): void { import { renderToStaticMarkup } from "react-dom/server"; import ActionToolbar, { ToolbarGroup, ToolbarSpacer } from "../src/components/ActionToolbar"; import ContentGrid, { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid"; +import ContentSection from "../src/components/ContentSection"; import Dialog from "../src/components/Dialog"; import { DialogForm, DialogSection } from "../src/components/DialogAnatomy"; import DescriptionList, { DescriptionItem } from "../src/components/DescriptionList"; +import DefinitionNodeIcon from "../src/components/DefinitionNodeIcon"; +import DefinitionPalette, { DefinitionPaletteGroup, DefinitionPaletteItem } from "../src/components/DefinitionPalette"; +import FilterBar from "../src/components/FilterBar"; +import FloatingStatus from "../src/components/FloatingStatus"; import FormSection from "../src/components/FormSection"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import MetricGrid from "../src/components/MetricGrid"; +import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList"; +import StatePanel from "../src/components/StatePanel"; +import WorkspaceLayout from "../src/components/WorkspaceLayout"; +import WorkspaceFrame from "../src/components/WorkspaceFrame"; const toolbarMarkup = renderToStaticMarkup( - + Filters Actions @@ -27,6 +36,7 @@ assert(toolbarMarkup.includes('role="toolbar"'), "named toolbars expose toolbar assert(toolbarMarkup.includes('aria-label="Object actions"'), "named toolbars expose their accessible name"); assert(toolbarMarkup.includes('data-help-scope="toolbar"'), "toolbar help scope is centralized"); assert(toolbarMarkup.includes("action-toolbar-group-grow"), "toolbar groups can own flexible space"); +assert(toolbarMarkup.includes("action-toolbar-justify-between"), "toolbar distribution is centrally controlled"); assert(toolbarMarkup.includes("action-toolbar-spacer"), "the standard action spacer is available"); const gridMarkup = renderToStaticMarkup( @@ -41,6 +51,11 @@ assert(gridMarkup.includes("content-grid-columns-3"), "content grids expose expl assert(gridMarkup.includes("content-grid-collapse-wide"), "content grids expose explicit collapse ownership"); assert(gridMarkup.includes("content-grid-item-full"), "grid items can span the complete layout"); +const sectionMarkup = renderToStaticMarkup(Section); +assert(sectionMarkup.includes("content-section-spacing-block"), "content sections own standard block rhythm"); +assert(sectionMarkup.includes("content-section-surface-subtle"), "content sections expose central surface variants"); +assert(sectionMarkup.includes("content-section-layout-stack"), "content sections can own compact stacked evidence anatomy"); + const metricMarkup = renderToStaticMarkup(
Open
Closed
@@ -67,6 +82,52 @@ const inlineDescriptionMarkup = renderToStaticMarkup( assert(inlineDescriptionMarkup.includes("description-list-columns-1"), "inline description lists default to one list column"); assert(inlineDescriptionMarkup.includes("description-list-term-compact"), "inline description lists centralize term sizing"); +const workspaceMarkup = renderToStaticMarkup( + + } + > + Create} /> + + +); + +assert(workspaceMarkup.includes("workspace-layout-surface-contained"), "bounded list-detail workspaces share their surface anatomy"); +assert(workspaceMarkup.includes("selection-list-navigation"), "resource navigation lists share selection geometry"); +assert(workspaceMarkup.includes("selection-list-item-content"), "selection-list copy owns title and description typography"); +assert(workspaceMarkup.includes("state-panel-size-fill"), "whole-surface states share sizing and action anatomy"); + +const frameMarkup = renderToStaticMarkup( + Body +); +assert(frameMarkup.includes("workspace-frame-surface-panel"), "full-height module frames share overflow and surface anatomy"); +assert(frameMarkup.startsWith(" + + + DB} label="Database" /> + + + Working... +
+); +assert(definitionMarkup.includes("definition-palette-items"), "definition editors share bounded palette anatomy"); +assert(definitionMarkup.includes("definition-node-icon"), "definition nodes share icon geometry"); +assert(definitionMarkup.includes("floating-status"), "floating activity state uses the central overlay"); + +const filterMarkup = renderToStaticMarkup( + +); +assert(filterMarkup.startsWith('
Reset}> diff --git a/webui/tests/page-layout.test.tsx b/webui/tests/page-layout.test.tsx index 09d0c0a..fd5783f 100644 --- a/webui/tests/page-layout.test.tsx +++ b/webui/tests/page-layout.test.tsx @@ -76,3 +76,14 @@ const headerLoadingMarkup = renderToStaticMarkup( assert(headerLoadingMarkup.includes("loading-indicator"), "page headings can expose partial refresh progress"); assert(!headerLoadingMarkup.includes('aria-busy="true"'), "partial refresh does not block the stable page body"); + +const delegatedHeaderMarkup = renderToStaticMarkup( + + +

Contributed administration panel

+
+
+); + +assert(!delegatedHeaderMarkup.includes("page-layout-header"), "composite workspaces can delegate their visible heading to contributed content"); +assert(delegatedHeaderMarkup.includes("page-layout-workspace content-pad workspace-data-page"), "headerless composite pages retain the central content frame");