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` | | 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` | | 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 | | 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` | | 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`, Campaign workspace, Campaign module workspace, Templates workspace, Approvals list/detail workspace, 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 | | 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 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 | | Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences |
| Metric groups | Reusable responsive grouping around metric cards | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides | `MetricGrid.tsx`, `MetricCard.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets | | Metric groups | 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 | | 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 | | 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 | | 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 ## 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 interchange, or conflict evidence. It is not a primary Core configuration
editor. editor.
Raw page-frame and full-canvas workspace debt is registered in the meta New headed pages use `PageLayout`; full-canvas modules use `WorkspaceFrame`
repository and may only decrease. New headed pages use `PageLayout`; pages and, where applicable, `WorkspaceLayout`, so Core owns the frame and pane
inside `WorkspaceLayout` use its `workspace` mode so the pane owns scrolling scrolling. Module CSS continues to own unequal domain content layout, never the
while the page retains the standard inset. Module CSS continues to own domain shared page, workspace, toolbar, state, list, filter, metric, section, or graph
content layout, never the shared page or workspace frame. Raw toolbar tags, chrome. Retired copies and module-local component definitions are rejected by
retired generic grid and description-list classes, raw dialog-form wrappers, `check-shared-webui-primitives.py`. That check also requires standard dialog
and module-local copies of the shared toolbar/grid/form-section/dialog-anatomy widths to use `Dialog size` and keeps every remaining domain-specific width in
components are rejected by `check-shared-webui-primitives.py`. That check also a reviewed, decrease-only exception baseline. The companion layout check now
requires standard dialog widths to use `Dialog size` and keeps every remaining has zero raw page-frame and zero raw workspace exceptions.
domain-specific width in a reviewed, decrease-only exception baseline.
+5 -1
View File
@@ -3,13 +3,15 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types"; import type { PlatformInterfaceIdentityProps } from "../types";
export type ActionToolbarDensity = "compact" | "default"; 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 ActionToolbarWrap = "responsive" | "always" | "never";
export type ActionToolbarJustify = "start" | "between" | "end";
export type ActionToolbarProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTMLDivElement>, "children"> & { export type ActionToolbarProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: ReactNode; children: ReactNode;
label?: string; label?: string;
density?: ActionToolbarDensity; density?: ActionToolbarDensity;
justify?: ActionToolbarJustify;
surface?: ActionToolbarSurface; surface?: ActionToolbarSurface;
wrap?: ActionToolbarWrap; wrap?: ActionToolbarWrap;
}; };
@@ -18,6 +20,7 @@ export default function ActionToolbar({
children, children,
label, label,
density = "default", density = "default",
justify = "start",
surface = "plain", surface = "plain",
wrap = "responsive", wrap = "responsive",
className = "", className = "",
@@ -38,6 +41,7 @@ export default function ActionToolbar({
className={[ className={[
"action-toolbar", "action-toolbar",
`action-toolbar-density-${density}`, `action-toolbar-density-${density}`,
`action-toolbar-justify-${justify}`,
`action-toolbar-surface-${surface}`, `action-toolbar-surface-${surface}`,
`action-toolbar-wrap-${wrap}`, `action-toolbar-wrap-${wrap}`,
className 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 { ChevronDown } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types"; import type { PlatformInterfaceIdentityProps } from "../types";
type CardProps = PlatformInterfaceIdentityProps & { export type CardProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTMLElement>, "children" | "title"> & {
title?: ReactNode; title?: ReactNode;
children: ReactNode; children: ReactNode;
actions?: ReactNode; actions?: ReactNode;
afterBody?: ReactNode;
collapsible?: boolean; collapsible?: boolean;
collapseKey?: string; collapseKey?: string;
persistCollapse?: boolean; persistCollapse?: boolean;
as?: "section" | "article";
headerClassName?: string;
bodyClassName?: string;
actionsClassName?: string;
}; };
function resolveCollapseStorageKey(collapsible: boolean, persistCollapse: boolean, collapseKey: string | undefined, title: ReactNode): string | null { 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. // 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 { translateText } = usePlatformLanguage();
const Element = as;
const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title); const storageKey = resolveCollapseStorageKey(collapsible, persistCollapse, collapseKey, title);
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) })); const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey); const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey);
const hasHeader = Boolean(title || actions || collapsible); 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 shouldRenderBody = !collapsible || !collapsed;
const collapseLabel = translateText(collapsed ? "i18n:govoplan-core.show_content.0528d8d2" : "i18n:govoplan-core.show_header_only.24afefca"); 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 ( return (
<section <Element
className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`} {...props}
className={["card", collapsible ? "card-collapsible" : "", collapsed ? "is-collapsed" : "", className].filter(Boolean).join(" ")}
data-help-scope="interface" data-help-scope="interface"
data-interface-id={interfaceId} data-interface-id={interfaceId}
data-help-context-id={helpContextId} 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} data-help-key={typeof title === "string" ? title : undefined}
> >
{hasHeader && {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>)} {title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
{(actions || collapsible) && {(actions || collapsible) &&
<div className="card-actions"> <div className={["card-actions", actionsClassName].filter(Boolean).join(" ")}>
{actions} {actions}
{collapsible && {collapsible &&
<button <button
@@ -91,7 +116,7 @@ export default function Card({ title, children, actions, collapsible = false, co
} }
</header> </header>
} }
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)} {shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}{afterBody}</div> : <>{body}{afterBody}</>)}
</section>); </Element>);
} }
+7
View File
@@ -2,12 +2,14 @@ import type { FormHTMLAttributes, HTMLAttributes, ReactNode } from "react";
export type ContentGridColumns = 1 | 2 | 3 | 4; export type ContentGridColumns = 1 | 2 | 3 | 4;
export type ContentGridGap = "compact" | "small" | "default" | "loose"; export type ContentGridGap = "compact" | "small" | "default" | "loose";
export type ContentGridSpacing = "none" | "block";
export type ContentGridCollapseAt = "narrow" | "workspace" | "standard" | "wide" | "never"; export type ContentGridCollapseAt = "narrow" | "workspace" | "standard" | "wide" | "never";
export type ContentGridProps = HTMLAttributes<HTMLDivElement> & { export type ContentGridProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode; children: ReactNode;
columns?: ContentGridColumns; columns?: ContentGridColumns;
gap?: ContentGridGap; gap?: ContentGridGap;
spacing?: ContentGridSpacing;
collapseAt?: ContentGridCollapseAt; collapseAt?: ContentGridCollapseAt;
align?: "start" | "stretch"; align?: "start" | "stretch";
}; };
@@ -16,6 +18,7 @@ export default function ContentGrid({
children, children,
columns = 2, columns = 2,
gap = "default", gap = "default",
spacing = "none",
collapseAt = "workspace", collapseAt = "workspace",
align = "start", align = "start",
className = "", className = "",
@@ -28,6 +31,7 @@ export default function ContentGrid({
"content-grid-layout", "content-grid-layout",
`content-grid-columns-${columns}`, `content-grid-columns-${columns}`,
`content-grid-gap-${gap}`, `content-grid-gap-${gap}`,
`content-grid-spacing-${spacing}`,
`content-grid-collapse-${collapseAt}`, `content-grid-collapse-${collapseAt}`,
`content-grid-align-${align}`, `content-grid-align-${align}`,
className className
@@ -66,6 +70,7 @@ export type FormLayoutProps = FormHTMLAttributes<HTMLFormElement> & {
children: ReactNode; children: ReactNode;
columns?: ContentGridColumns; columns?: ContentGridColumns;
gap?: ContentGridGap; gap?: ContentGridGap;
spacing?: ContentGridSpacing;
collapseAt?: ContentGridCollapseAt; collapseAt?: ContentGridCollapseAt;
}; };
@@ -73,6 +78,7 @@ export function FormLayout({
children, children,
columns = 1, columns = 1,
gap = "default", gap = "default",
spacing = "none",
collapseAt = "standard", collapseAt = "standard",
className = "", className = "",
...props ...props
@@ -85,6 +91,7 @@ export function FormLayout({
"form-grid-layout", "form-grid-layout",
`content-grid-columns-${columns}`, `content-grid-columns-${columns}`,
`content-grid-gap-${gap}`, `content-grid-gap-${gap}`,
`content-grid-spacing-${spacing}`,
`content-grid-collapse-${collapseAt}`, `content-grid-collapse-${collapseAt}`,
"content-grid-align-start", "content-grid-align-start",
className 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 { 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 ( return (
<div className={`metric-card metric-${tone}`}> <div {...props} className={[
<div className="metric-label">{translateText(label)}</div> "metric-card",
<div className="metric-value">{renderedValue}</div> `metric-${tone}`,
{detail && <div className="metric-detail">{translateText(detail)}</div>} `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> </div>
); );
} }
+4
View File
@@ -61,6 +61,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
mode?: PageLayoutMode; mode?: PageLayoutMode;
scrollable?: boolean; scrollable?: boolean;
stickyHeader?: boolean; stickyHeader?: boolean;
showHeader?: boolean;
documentationType?: "user" | "admin"; documentationType?: "user" | "admin";
className?: string; className?: string;
viewportClassName?: string; viewportClassName?: string;
@@ -82,6 +83,7 @@ export default function PageLayout({
mode = "standalone", mode = "standalone",
scrollable, scrollable,
stickyHeader = true, stickyHeader = true,
showHeader = true,
documentationType = "user", documentationType = "user",
className = "", className = "",
viewportClassName = "", viewportClassName = "",
@@ -112,6 +114,7 @@ export default function PageLayout({
data-help-documentation-type={documentationType} data-help-documentation-type={documentationType}
data-help-key={typeof title === "string" ? title : undefined} data-help-key={typeof title === "string" ? title : undefined}
> >
{showHeader && (
<PageHeader <PageHeader
title={title} title={title}
description={description} description={description}
@@ -120,6 +123,7 @@ export default function PageLayout({
sticky={stickyHeader} sticky={stickyHeader}
className={headerClassName} className={headerClassName}
/> />
)}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>} {success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{notices && <div className="page-layout-notices">{notices}</div>} {notices && <div className="page-layout-notices">{notices}</div>}
+4 -3
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel"; import FieldLabel from "./help/FieldLabel";
import ActionToolbar from "./ActionToolbar";
type PolicySectionProps = { type PolicySectionProps = {
title?: ReactNode; title?: ReactNode;
@@ -44,15 +45,15 @@ export function PolicySection({
actions, actions,
children, children,
className = "", className = "",
headingClassName = "subsection-heading split" headingClassName = ""
}: PolicySectionProps) { }: PolicySectionProps) {
return ( return (
<section className={["policy-section", className].filter(Boolean).join(" ")}> <section className={["policy-section", className].filter(Boolean).join(" ")}>
{(title || summary || actions) && {(title || summary || actions) &&
<div className={headingClassName}> <ActionToolbar surface="section-header" className={headingClassName}>
{title && <h3>{title}</h3>} {title && <h3>{title}</h3>}
{actions ?? summary} {actions ?? summary}
</div> </ActionToolbar>
} }
{children} {children}
</section>); </section>);
@@ -1,4 +1,4 @@
import { FormGrid } from "./ContentGrid"; import ContentGrid, { FormGrid } from "./ContentGrid";
import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts"; import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts";
export type ResourceAccessExplanationProps = { export type ResourceAccessExplanationProps = {
@@ -33,7 +33,7 @@ export default function ResourceAccessExplanation({
{explanation.provenance.length === 0 ? ( {explanation.provenance.length === 0 ? (
<p className="muted small-note">i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e</p> <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) => ( {explanation.provenance.map((item, index) => (
<div key={`${item.kind}:${item.id ?? index}`}> <div key={`${item.kind}:${item.id ?? index}`}>
<strong>{resourceAccessProvenanceKindLabel(item.kind)}</strong> <strong>{resourceAccessProvenanceKindLabel(item.kind)}</strong>
@@ -47,7 +47,7 @@ export default function ResourceAccessExplanation({
)} )}
</div> </div>
))} ))}
</div> </ContentGrid>
)} )}
</> </>
); );
+27 -1
View File
@@ -4,6 +4,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
export type SelectionListProps = Omit<HTMLAttributes<HTMLDivElement>, "children" | "role"> & { export type SelectionListProps = Omit<HTMLAttributes<HTMLDivElement>, "children" | "role"> & {
children: ReactNode; children: ReactNode;
label?: string; label?: string;
variant?: "plain" | "navigation";
}; };
export type SelectionListItemProps = Omit< export type SelectionListItemProps = Omit<
@@ -19,10 +20,11 @@ export default function SelectionList({
children, children,
className = "", className = "",
label, label,
variant = "plain",
...props ...props
}: SelectionListProps) { }: SelectionListProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const rootClassName = ["selection-list", className].filter(Boolean).join(" "); const rootClassName = ["selection-list", `selection-list-${variant}`, className].filter(Boolean).join(" ");
return ( return (
<div <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({ export function SelectionListItem({
children, children,
className = "", 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 WorkspaceLayoutVariant = "navigation" | "split";
export type WorkspacePrimarySize = "compact" | "default" | "wide"; export type WorkspacePrimarySize = "compact" | "default" | "wide";
export type WorkspaceLayoutSurface = "plain" | "contained";
export type WorkspaceLayoutProps = PlatformInterfaceIdentityProps & { export type WorkspaceLayoutProps = PlatformInterfaceIdentityProps & {
primary: ReactNode; primary: ReactNode;
@@ -15,6 +16,7 @@ export type WorkspaceLayoutProps = PlatformInterfaceIdentityProps & {
documentationType?: "user" | "admin"; documentationType?: "user" | "admin";
primaryScrollable?: boolean; primaryScrollable?: boolean;
contentScrollable?: boolean; contentScrollable?: boolean;
surface?: WorkspaceLayoutSurface;
className?: string; className?: string;
primaryClassName?: string; primaryClassName?: string;
contentClassName?: string; contentClassName?: string;
@@ -30,6 +32,7 @@ export default function WorkspaceLayout({
documentationType = "user", documentationType = "user",
primaryScrollable, primaryScrollable,
contentScrollable = true, contentScrollable = true,
surface = "plain",
className = "", className = "",
primaryClassName = "", primaryClassName = "",
contentClassName = "", contentClassName = "",
@@ -48,6 +51,7 @@ export default function WorkspaceLayout({
"workspace-layout", "workspace-layout",
`workspace-layout-${variant}`, `workspace-layout-${variant}`,
`workspace-layout-primary-${primarySize}`, `workspace-layout-primary-${primarySize}`,
`workspace-layout-surface-${surface}`,
className className
].filter(Boolean).join(" ")} ].filter(Boolean).join(" ")}
data-help-scope="workspace" data-help-scope="workspace"
+16 -15
View File
@@ -7,7 +7,7 @@ import Card from "../../components/Card";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField"; import PasswordField from "../../components/PasswordField";
import Button from "../../components/Button"; import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle"; import PageLayout from "../../components/PageLayout";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client"; import { apiFetch } from "../../api/client";
import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth"; import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
@@ -22,6 +22,7 @@ import { hasAnyScope, hasScope } from "../../utils/permissions";
import { usePlatformLanguage } from "../../i18n/LanguageContext"; import { usePlatformLanguage } from "../../i18n/LanguageContext";
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager"; import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
import DocumentationHelpLink from "../../components/help/DocumentationHelpLink"; import DocumentationHelpLink from "../../components/help/DocumentationHelpLink";
import WorkspaceLayout from "../../components/WorkspaceLayout";
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string; type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
@@ -306,17 +307,18 @@ export default function SettingsPage({
} }
return ( return (
<div className="workspace module-workspace"> <WorkspaceLayout
<ModuleSubnav active={active} groups={settingsSubnav} onSelect={selectSection} /> className="module-workspace"
<section className="workspace-content"> primary={<ModuleSubnav active={active} groups={settingsSubnav} onSelect={selectSection} />}
<div className="content-pad workspace-data-page"> primaryLabel="i18n:govoplan-core.settings.c7f73bb5"
<div className="page-heading split workspace-heading"> contentLabel="i18n:govoplan-core.settings.c7f73bb5"
<div> >
<PageTitle>i18n:govoplan-core.settings.c7f73bb5</PageTitle> <PageLayout
<p>i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56</p> title="i18n:govoplan-core.settings.c7f73bb5"
</div> description="i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56"
<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} /> actions={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
</div> mode="workspace"
>
{active === "profile" && {active === "profile" &&
<ContentGrid columns={2} collapseAt="workspace" className=""> <ContentGrid columns={2} collapseAt="workspace" className="">
@@ -551,9 +553,8 @@ export default function SettingsPage({
selectSection selectSection
}) })
} }
</div> </PageLayout>
</section> </WorkspaceLayout>);
</div>);
} }
+22 -4
View File
@@ -50,7 +50,7 @@ export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessB
export { default as ActionBlockerHint } from "./components/ActionBlockerHint"; export { default as ActionBlockerHint } from "./components/ActionBlockerHint";
export type { ActionBlockerLabels, ActionBlockerReason } from "./components/ActionBlockerHint"; export type { ActionBlockerLabels, ActionBlockerReason } from "./components/ActionBlockerHint";
export { default as ActionToolbar, ToolbarGroup, ToolbarSpacer } from "./components/ActionToolbar"; 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 AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel";
export { default as AdminIconButton } from "./components/admin/AdminIconButton"; export { default as AdminIconButton } from "./components/admin/AdminIconButton";
export type { AdminIconButtonProps } 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 { default as Button } from "./components/Button";
export type { ButtonProps } from "./components/Button"; export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card"; 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 ConfirmDialog } from "./components/ConfirmDialog";
export { export {
default as ConcurrencyConflictDialog, default as ConcurrencyConflictDialog,
@@ -91,6 +94,10 @@ export type { DialogBodyPadding, DialogProps, DialogSize, DialogVariant } from "
export { DialogActions, DialogForm, DialogSection } from "./components/DialogAnatomy"; export { DialogActions, DialogForm, DialogSection } from "./components/DialogAnatomy";
export type { DialogActionAlignment, DialogActionsProps, DialogFormProps, DialogSectionProps } from "./components/DialogAnatomy"; export type { DialogActionAlignment, DialogActionsProps, DialogFormProps, DialogSectionProps } from "./components/DialogAnatomy";
export { default as DescriptionList, DescriptionItem } from "./components/DescriptionList"; 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 type { DescriptionItemProps, DescriptionListCollapseAt, DescriptionListColumns, DescriptionListDensity, DescriptionListProps, DescriptionListTermWidth, DescriptionListVariant } from "./components/DescriptionList";
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip"; export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
export type { DisabledActionTooltipProps } 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 type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
export { default as FileDropZone } from "./components/FileDropZone"; export { default as FileDropZone } from "./components/FileDropZone";
export type { FileDropZoneProps } 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 FormField } from "./components/FormField";
export { default as ContentGrid, FormGrid, FormLayout, GridItem } from "./components/ContentGrid"; export { default as ContentGrid, FormGrid, FormLayout, GridItem } from "./components/ContentGrid";
export type { ContentGridCollapseAt, ContentGridColumns, ContentGridGap, ContentGridProps, FormGridProps, FormLayoutProps, GridItemProps } 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 { default as FormSection } from "./components/FormSection";
export type { FormSectionProps, FormSectionVariant } from "./components/FormSection"; export type { FormSectionProps, FormSectionVariant } from "./components/FormSection";
export { default as GuidedConfigDialog } from "./components/GuidedConfigDialog"; 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 { default as ExplorerTree } from "./components/ExplorerTree";
export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree"; export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree";
export { default as MetricCard } from "./components/MetricCard"; export { default as MetricCard } from "./components/MetricCard";
export type { MetricCardProps } from "./components/MetricCard";
export { default as MetricGrid } from "./components/MetricGrid"; export { default as MetricGrid } from "./components/MetricGrid";
export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid"; export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid";
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel"; 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 { default as PageScrollViewport } from "./components/PageScrollViewport";
export type { PageScrollViewportProps } from "./components/PageScrollViewport"; export type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as WorkspaceLayout } from "./components/WorkspaceLayout"; 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 PasswordField } from "./components/PasswordField";
export { default as PasswordGeneratorDialog } from "./components/PasswordGeneratorDialog"; export { default as PasswordGeneratorDialog } from "./components/PasswordGeneratorDialog";
export { default as PeoplePicker } from "./components/people/PeoplePicker"; 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 type { DashboardWidgetDataState, DashboardWidgetListItem } from "./components/DashboardWidgetContent";
export { default as SegmentedControl } from "./components/SegmentedControl"; export { default as SegmentedControl } from "./components/SegmentedControl";
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl"; export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
export { default as SelectionList, SelectionListItem } from "./components/SelectionList"; export { default as SelectionList, SelectionListItem, SelectionListItemContent } from "./components/SelectionList";
export type { SelectionListItemProps, SelectionListProps } 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 StatusBadge } from "./components/StatusBadge";
export { default as StageRail } from "./components/StageRail"; export { default as StageRail } from "./components/StageRail";
export type { export type {
+3 -2
View File
@@ -6,6 +6,7 @@ import LanguageMenu from "./LanguageMenu";
import TemporalDataMenu from "./TemporalDataMenu"; import TemporalDataMenu from "./TemporalDataMenu";
import LoginModal from "../features/auth/LoginModal"; import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert"; import DismissibleAlert from "../components/DismissibleAlert";
import CountBadge from "../components/CountBadge";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { useSharedNotificationSummary } from "../api/notificationSummary"; import { useSharedNotificationSummary } from "../api/notificationSummary";
import { logout, switchTenant } from "../api/auth"; 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")}> <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} /> <Bell size={18} />
{unreadNotificationCount > 0 && {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} {notificationBadgeLabel}
</span> </CountBadge>
} }
</button> </button>
} }
+136 -11
View File
@@ -1046,16 +1046,6 @@
height: 17px; 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 { .admin-selection-list {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -1093,7 +1083,6 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.admin-assignment-grid,
.admin-permission-groups { .admin-permission-groups {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -2986,6 +2975,142 @@
min-width: 0; 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) { :where(.selection-list-item) {
appearance: none; appearance: none;
box-sizing: border-box; box-sizing: border-box;
+1
View File
@@ -7,6 +7,7 @@
.content-grid-gap-small { gap: 14px; } .content-grid-gap-small { gap: 14px; }
.content-grid-gap-default { gap: 18px; } .content-grid-gap-default { gap: 18px; }
.content-grid-gap-loose { gap: 26px; } .content-grid-gap-loose { gap: 26px; }
.content-grid-spacing-block { margin-block: 18px; }
.content-grid-align-start { align-items: start; } .content-grid-align-start { align-items: start; }
.content-grid-align-stretch { align-items: stretch; } .content-grid-align-stretch { align-items: stretch; }
.content-grid-item { min-width: 0; } .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 { 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-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-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); } .account-pill { color: var(--text); }
.maintenance-topbar-link, .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; } .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 { min-width: 0; display: flex; align-items: center; gap: 10px; }
.action-toolbar-density-compact { gap: 7px; } .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-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-always { flex-wrap: wrap; }
.action-toolbar-wrap-never { flex-wrap: nowrap; overflow-x: auto; } .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 { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.action-toolbar-group-center { justify-content: center; } .action-toolbar-group-center { justify-content: center; }
.action-toolbar-group-end { justify-content: flex-end; margin-inline-start: auto; } .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-group-grow { flex: 1 1 auto; }
.action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; } .action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; }
.app-content { min-height: 0; overflow: hidden; } .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 { 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-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-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-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 { font-size: 12px; font-weight: 800; color: var(--muted); padding: 0 22px 14px; letter-spacing: .06em; }
.section-title-lower { margin-top: 28px; } .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-content { overflow: visible; }
.ui-no-sticky-section-sidebars .workspace-layout-primary { 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-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 { margin-bottom: 22px; }
.page-heading h1 { margin: 0; font-size: 26px; color: var(--text-strong); font-weight: 600; } .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); } .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-default { --metric-group-column-minimum: 140px; }
.metric-group-minimum-wide { --metric-group-column-minimum: 180px; } .metric-group-minimum-wide { --metric-group-column-minimum: 180px; }
.metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); } .metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); }
.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-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-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; } .metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
@@ -297,6 +327,7 @@
.workspace { grid-template-columns: 1fr; } .workspace { grid-template-columns: 1fr; }
.workspace-layout-navigation > .workspace-layout-primary { display: none; } .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-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; } .section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; } .wizard-card { grid-template-columns: 1fr; }
.metric-group-collapse-workspace { grid-template-columns: minmax(0, 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 { align-items: stretch; flex-wrap: wrap; }
.action-toolbar-wrap-responsive > .action-toolbar-group { flex: 1 1 100%; } .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; } .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 { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; }
.empty-state h2 { margin: 0; color: var(--text-strong); } .empty-state h2 { margin: 0; color: var(--text-strong); }
.empty-state p { max-width: 520px; margin: 8px auto 18px; color: var(--muted); } .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-workspace-content { background: var(--panel); }
.docs-outline { padding-top: 22px; } .docs-outline { padding-top: 22px; }
.docs-sidebar-header { padding: 0 16px 16px; } .docs-sidebar-header { padding: 0 16px 16px; }
@@ -414,7 +487,6 @@
margin-left: 0; margin-left: 0;
} }
.summary-grid { grid-template-columns: 1fr; } .summary-grid { grid-template-columns: 1fr; }
.docs-workspace { grid-template-columns: 1fr; }
.docs-outline { display: none; } .docs-outline { display: none; }
.docs-content { grid-template-columns: 1fr; } .docs-content { grid-template-columns: 1fr; }
.docs-page-outline-box { display: none; } .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 { renderToStaticMarkup } from "react-dom/server";
import ActionToolbar, { ToolbarGroup, ToolbarSpacer } from "../src/components/ActionToolbar"; import ActionToolbar, { ToolbarGroup, ToolbarSpacer } from "../src/components/ActionToolbar";
import ContentGrid, { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid"; import ContentGrid, { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid";
import ContentSection from "../src/components/ContentSection";
import Dialog from "../src/components/Dialog"; import Dialog from "../src/components/Dialog";
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy"; import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
import DescriptionList, { DescriptionItem } from "../src/components/DescriptionList"; 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 FormSection from "../src/components/FormSection";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import MetricGrid from "../src/components/MetricGrid"; 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( const toolbarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <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> <ToolbarGroup grow>Filters</ToolbarGroup>
<ToolbarSpacer /> <ToolbarSpacer />
<ToolbarGroup align="end">Actions</ToolbarGroup> <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('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('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-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"); assert(toolbarMarkup.includes("action-toolbar-spacer"), "the standard action spacer is available");
const gridMarkup = renderToStaticMarkup( 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-collapse-wide"), "content grids expose explicit collapse ownership");
assert(gridMarkup.includes("content-grid-item-full"), "grid items can span the complete layout"); 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( const metricMarkup = renderToStaticMarkup(
<MetricGrid columns="auto" density="compact" spacing="inset" minimum="compact" collapseAt="standard"> <MetricGrid columns="auto" density="compact" spacing="inset" minimum="compact" collapseAt="standard">
<div>Open</div><div>Closed</div> <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-columns-1"), "inline description lists default to one list column");
assert(inlineDescriptionMarkup.includes("description-list-term-compact"), "inline description lists centralize term sizing"); 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( const formMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<FormSection title="Identity" description="Stable identifying values" actions={<button type="button">Reset</button>}> <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("loading-indicator"), "page headings can expose partial refresh progress");
assert(!headerLoadingMarkup.includes('aria-busy="true"'), "partial refresh does not block the stable page body"); 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");