Centralize shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:31 +02:00
parent ee5c881df9
commit 7685a103e8
26 changed files with 767 additions and 81 deletions
+15 -12
View File
@@ -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 14 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
| Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences |
| Metric groups | Reusable responsive grouping around metric cards | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides | `MetricGrid.tsx`, `MetricCard.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets |
| 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.
+5 -1
View File
@@ -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<HTMLAttributes<HTMLDivElement>, "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
+35 -10
View File
@@ -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<HTMLAttributes<HTMLElement>, "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 = <div className="card-body">{children}</div>;
const body = <div className={["card-body", bodyClassName].filter(Boolean).join(" ")}>{children}</div>;
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 (
<section
className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}
<Element
{...props}
className={["card", collapsible ? "card-collapsible" : "", collapsed ? "is-collapsed" : "", className].filter(Boolean).join(" ")}
data-help-scope="interface"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
@@ -70,10 +95,10 @@ export default function Card({ title, children, actions, collapsible = false, co
data-help-key={typeof title === "string" ? title : undefined}
>
{hasHeader &&
<header className="card-header">
<header className={["card-header", headerClassName].filter(Boolean).join(" ")}>
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
{(actions || collapsible) &&
<div className="card-actions">
<div className={["card-actions", actionsClassName].filter(Boolean).join(" ")}>
{actions}
{collapsible &&
<button
@@ -91,7 +116,7 @@ export default function Card({ title, children, actions, collapsible = false, co
}
</header>
}
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
</section>);
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}{afterBody}</div> : <>{body}{afterBody}</>)}
</Element>);
}
+7
View File
@@ -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<HTMLDivElement> & {
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<HTMLFormElement> & {
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
+37
View File
@@ -0,0 +1,37 @@
import type { HTMLAttributes, ReactNode } from "react";
export type ContentSectionProps = Omit<HTMLAttributes<HTMLElement>, "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 (
<Component
{...props}
className={[
"content-section",
`content-section-density-${density}`,
`content-section-layout-${layout}`,
`content-section-spacing-${spacing}`,
`content-section-surface-${surface}`,
className
].filter(Boolean).join(" ")}
>
{children}
</Component>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { HTMLAttributes, ReactNode } from "react";
export type CountBadgeProps = HTMLAttributes<HTMLSpanElement> & {
children: ReactNode;
tone?: "accent" | "neutral" | "danger";
size?: "compact" | "default";
};
export default function CountBadge({
children,
tone = "accent",
size = "default",
className = "",
...props
}: CountBadgeProps) {
return (
<span
{...props}
className={[
"count-badge",
`count-badge-${tone}`,
`count-badge-${size}`,
className
].filter(Boolean).join(" ")}
>
{children}
</span>
);
}
@@ -0,0 +1,12 @@
import type { HTMLAttributes } from "react";
export type DefinitionNodeIconProps = HTMLAttributes<HTMLSpanElement>;
export default function DefinitionNodeIcon({ className = "", ...props }: DefinitionNodeIconProps) {
return (
<span
{...props}
className={["definition-node-icon", className].filter(Boolean).join(" ")}
/>
);
}
@@ -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<HTMLAttributes<HTMLElement>, "children"> & {
label: ReactNode;
description?: ReactNode;
actions?: ReactNode;
children: ReactNode;
};
export default function DefinitionPalette({ label, description, actions, children, className = "", ...props }: DefinitionPaletteProps) {
const { translateText } = usePlatformLanguage();
return (
<aside {...props} className={["definition-palette", className].filter(Boolean).join(" ")}>
<ActionToolbar surface="section-header" className="definition-palette-header">
<span className="definition-palette-heading-copy">
<strong>{translateReactNode(label, translateText)}</strong>
{description ? <small>{translateReactNode(description, translateText)}</small> : null}
</span>
{actions}
</ActionToolbar>
<div className="definition-palette-items">{children}</div>
</aside>
);
}
export function DefinitionPaletteGroup({ label, children, className = "", ...props }: Omit<HTMLAttributes<HTMLElement>, "title"> & { label: ReactNode }) {
const { translateText } = usePlatformLanguage();
return (
<section {...props} className={["definition-palette-group", className].filter(Boolean).join(" ")}>
<h3>{translateReactNode(label, translateText)}</h3>
{children}
</section>
);
}
export type DefinitionPaletteItemProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
icon: ReactNode;
label: ReactNode;
};
export function DefinitionPaletteItem({ icon, label, className = "", type = "button", ...props }: DefinitionPaletteItemProps) {
const { translateText } = usePlatformLanguage();
return (
<button {...props} type={type} className={["definition-palette-item", className].filter(Boolean).join(" ")}>
<span className="definition-palette-item-icon" aria-hidden="true">{icon}</span>
<span>{translateReactNode(label, translateText)}</span>
<Plus size={14} className="definition-palette-item-add" aria-hidden="true" />
</button>
);
}
+42
View File
@@ -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<HTMLElement> & {
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 (
<Element
{...props}
className={[
"filter-bar",
`filter-bar-layout-${layout}`,
`filter-bar-surface-${surface}`,
`filter-bar-width-${width}`,
`filter-bar-wrap-${wrap}`,
className
].filter(Boolean).join(" ")}
>
{children}
</Element>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { HTMLAttributes, ReactNode } from "react";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
export type FloatingStatusProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
};
export default function FloatingStatus({ children, className = "", role = "status", ...props }: FloatingStatusProps) {
const { translateText } = usePlatformLanguage();
return (
<div
{...props}
role={role}
className={["floating-status", className].filter(Boolean).join(" ")}
>
{translateReactNode(children, translateText)}
</div>
);
}
+36 -7
View File
@@ -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<HTMLDivElement> & {
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 (
<div className={`metric-card metric-${tone}`}>
<div className="metric-label">{translateText(label)}</div>
<div className="metric-value">{renderedValue}</div>
{detail && <div className="metric-detail">{translateText(detail)}</div>}
<div {...props} className={[
"metric-card",
`metric-${tone}`,
`metric-card-density-${density}`,
`metric-card-surface-${surface}`,
className
].filter(Boolean).join(" ")}>
<div className="metric-label">{renderedLabel}</div>
<div className="metric-value" title={valueTitle}>{renderedValue}</div>
{renderedDetail ? <div className="metric-detail">{renderedDetail}</div> : null}
</div>
);
}
+12 -8
View File
@@ -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}
>
<PageHeader
title={title}
description={description}
actions={actions}
loading={headerLoading ?? loading}
sticky={stickyHeader}
className={headerClassName}
/>
{showHeader && (
<PageHeader
title={title}
description={description}
actions={actions}
loading={headerLoading ?? loading}
sticky={stickyHeader}
className={headerClassName}
/>
)}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{notices && <div className="page-layout-notices">{notices}</div>}
+4 -3
View File
@@ -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 (
<section className={["policy-section", className].filter(Boolean).join(" ")}>
{(title || summary || actions) &&
<div className={headingClassName}>
<ActionToolbar surface="section-header" className={headingClassName}>
{title && <h3>{title}</h3>}
{actions ?? summary}
</div>
</ActionToolbar>
}
{children}
</section>);
@@ -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 ? (
<p className="muted small-note">i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e</p>
) : (
<div className="admin-assignment-grid">
<ContentGrid columns={2} gap="default" spacing="block" collapseAt="wide">
{explanation.provenance.map((item, index) => (
<div key={`${item.kind}:${item.id ?? index}`}>
<strong>{resourceAccessProvenanceKindLabel(item.kind)}</strong>
@@ -47,7 +47,7 @@ export default function ResourceAccessExplanation({
)}
</div>
))}
</div>
</ContentGrid>
)}
</>
);
+27 -1
View File
@@ -4,6 +4,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
export type SelectionListProps = Omit<HTMLAttributes<HTMLDivElement>, "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 (
<div
@@ -36,6 +38,30 @@ export default function SelectionList({
);
}
export type SelectionListItemContentProps = HTMLAttributes<HTMLSpanElement> & {
title: ReactNode;
description?: ReactNode;
leading?: ReactNode;
};
export function SelectionListItemContent({
title,
description,
leading,
className = "",
...props
}: SelectionListItemContentProps) {
return (
<span {...props} className={["selection-list-item-content", leading ? "has-leading" : "", className].filter(Boolean).join(" ")}>
{leading ? <span className="selection-list-item-leading" aria-hidden="true">{leading}</span> : null}
<span className="selection-list-item-copy">
<strong>{title}</strong>
{description ? <small>{description}</small> : null}
</span>
</span>
);
}
export function SelectionListItem({
children,
className = "",
+55
View File
@@ -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<HTMLAttributes<HTMLDivElement>, "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 (
<div
{...props}
className={[
"state-panel",
`state-panel-size-${size}`,
`state-panel-surface-${surface}`,
`state-panel-tone-${tone}`,
`state-panel-align-${align}`,
className
].filter(Boolean).join(" ")}
>
{icon ? <div className="state-panel-icon" aria-hidden="true">{icon}</div> : null}
{translatedTitle ? <h2 className="state-panel-title">{translatedTitle}</h2> : null}
{translatedDescription ? <div className="state-panel-description">{translatedDescription}</div> : null}
{children ? <div className="state-panel-content">{children}</div> : null}
{actions ? <div className="state-panel-actions">{actions}</div> : null}
</div>
);
}
+45
View File
@@ -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<HTMLAttributes<HTMLElement>, "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 (
<Component
{...props}
className={["workspace-frame", `workspace-frame-height-${height}`, `workspace-frame-surface-${surface}`, className].filter(Boolean).join(" ")}
role={label ? "region" : undefined}
aria-label={label ? translateText(label) : undefined}
data-help-scope="workspace"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
data-help-documentation-type={documentationType}
>
{children}
</Component>
);
}
+4
View File
@@ -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"
+16 -15
View File
@@ -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 (
<div className="workspace module-workspace">
<ModuleSubnav active={active} groups={settingsSubnav} onSelect={selectSection} />
<section className="workspace-content">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-core.settings.c7f73bb5</PageTitle>
<p>i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56</p>
</div>
<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />
</div>
<WorkspaceLayout
className="module-workspace"
primary={<ModuleSubnav active={active} groups={settingsSubnav} onSelect={selectSection} />}
primaryLabel="i18n:govoplan-core.settings.c7f73bb5"
contentLabel="i18n:govoplan-core.settings.c7f73bb5"
>
<PageLayout
title="i18n:govoplan-core.settings.c7f73bb5"
description="i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56"
actions={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
mode="workspace"
>
{active === "profile" &&
<ContentGrid columns={2} collapseAt="workspace" className="">
@@ -551,9 +553,8 @@ export default function SettingsPage({
selectSection
})
}
</div>
</section>
</div>);
</PageLayout>
</WorkspaceLayout>);
}
+22 -4
View File
@@ -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 {
+3 -2
View File
@@ -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
<button className="titlebar-icon-link titlebar-notification-button" data-help-context-id="notifications.route.notifications" data-help-module-id="notifications" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
<Bell size={18} />
{unreadNotificationCount > 0 &&
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
<CountBadge tone="danger" size="compact" className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
{notificationBadgeLabel}
</span>
</CountBadge>
}
</button>
}
+136 -11
View File
@@ -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;
+1
View File
@@ -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; }
+75 -3
View File
@@ -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; }
+62 -1
View File
@@ -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(
<PlatformLanguageProvider>
<ActionToolbar label="Object actions" density="compact" surface="subtle" interfaceId="object.toolbar">
<ActionToolbar label="Object actions" density="compact" justify="between" surface="subtle" interfaceId="object.toolbar">
<ToolbarGroup grow>Filters</ToolbarGroup>
<ToolbarSpacer />
<ToolbarGroup align="end">Actions</ToolbarGroup>
@@ -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(<ContentSection density="compact" layout="stack" surface="subtle">Section</ContentSection>);
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(
<MetricGrid columns="auto" density="compact" spacing="inset" minimum="compact" collapseAt="standard">
<div>Open</div><div>Closed</div>
@@ -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(
<PlatformLanguageProvider>
<WorkspaceLayout
variant="split"
surface="contained"
primaryLabel="Objects"
primary={<SelectionList variant="navigation" label="Objects"><SelectionListItem selected><SelectionListItemContent title="Object" description="Draft" /></SelectionListItem></SelectionList>}
>
<StatePanel size="fill" title="No object selected" actions={<button type="button">Create</button>} />
</WorkspaceLayout>
</PlatformLanguageProvider>
);
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(
<PlatformLanguageProvider><WorkspaceFrame as="main" height="viewport" label="Planning workspace" surface="panel"><span>Body</span></WorkspaceFrame></PlatformLanguageProvider>
);
assert(frameMarkup.includes("workspace-frame-surface-panel"), "full-height module frames share overflow and surface anatomy");
assert(frameMarkup.startsWith("<main"), "workspace frames preserve the owning page landmark");
assert(frameMarkup.includes("workspace-frame-height-viewport"), "workspace frames centralize application viewport sizing");
assert(frameMarkup.includes('aria-label="Planning workspace"'), "workspace frames expose their accessible name");
const definitionMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<DefinitionPalette label="Nodes" description="Drag onto the canvas">
<DefinitionPaletteGroup label="Sources">
<DefinitionPaletteItem icon={<DefinitionNodeIcon>DB</DefinitionNodeIcon>} label="Database" />
</DefinitionPaletteGroup>
</DefinitionPalette>
<FloatingStatus>Working...</FloatingStatus>
</PlatformLanguageProvider>
);
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(
<FilterBar as="form" surface="panel" width="compact"><input aria-label="Search" /><button type="submit">Find</button></FilterBar>
);
assert(filterMarkup.startsWith('<form class="filter-bar'), "filter bars can preserve native form semantics");
assert(filterMarkup.includes("filter-bar-surface-panel"), "filter placement and surface are centralized");
const formMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<FormSection title="Identity" description="Stable identifying values" actions={<button type="button">Reset</button>}>
+11
View File
@@ -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(
<PlatformLanguageProvider>
<PageLayout title="Administration" mode="workspace" showHeader={false}>
<section><h1>Contributed administration panel</h1></section>
</PageLayout>
</PlatformLanguageProvider>
);
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");