Add shared WebUI layout primitives

This commit is contained in:
2026-08-18 10:42:55 +02:00
parent dd7ad4d9c7
commit 6814a41ae4
22 changed files with 574 additions and 111 deletions
+8 -1
View File
@@ -15,6 +15,10 @@ domain modules own their compositions.
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
| Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Standalone, workspace, and embedded modes make inset and scroll ownership explicit; sticky heading, rich descriptions, route actions, notices, loading, narrow-layout collapse, and contextual-help identity are centralized; `AdminPageLayout` composes the same contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core fallback dashboard, Dashboard module, Ops module, Campaign pages, and `check-shared-webui-layouts.py` |
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Campaign workspace, Campaign module workspace, Templates workspace, Approvals list/detail workspace, and `check-shared-webui-layouts.py` |
| 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 |
| 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 |
| 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 |
| 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 |
| 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
@@ -33,4 +37,7 @@ Raw page-frame and full-canvas workspace debt is registered in the meta
repository and may only decrease. New headed pages use `PageLayout`; pages
inside `WorkspaceLayout` use its `workspace` mode so the pane owns scrolling
while the page retains the standard inset. Module CSS continues to own domain
content layout, never the shared page or workspace frame.
content layout, never the shared page or workspace frame. Raw toolbar tags,
retired generic grid classes, raw dialog-form wrappers, and module-local copies
of the shared toolbar/grid/form-section/dialog-anatomy components are rejected
by `check-shared-webui-primitives.py`.
+1
View File
@@ -39,6 +39,7 @@
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/layout-primitives.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/definition-graph.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
@@ -18,6 +18,10 @@ const helpMenu = read("src/layout/HelpMenu.tsx");
const helpContext = read("src/utils/helpContext.ts");
const pageLayout = read("src/components/PageLayout.tsx");
const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
const actionToolbar = read("src/components/ActionToolbar.tsx");
const contentGrid = read("src/components/ContentGrid.tsx");
const formSection = read("src/components/FormSection.tsx");
const dialogAnatomy = read("src/components/DialogAnatomy.tsx");
const adminPageLayout = read("src/components/admin/AdminPageLayout.tsx");
const layoutStyles = read("src/styles/layout.css");
const authGateStyles = read("src/styles/auth-gate.css");
@@ -52,6 +56,11 @@ assert.match(pageLayout, /data-help-scope="page"/, "shared pages preserve contex
assert.match(adminPageLayout, /<PageLayout/, "administration composes the central page layout instead of redefining it");
assert.match(workspaceLayout, /workspace-layout-\$\{variant\}/, "shared workspaces expose central navigation and split-pane variants");
assert.match(workspaceLayout, /data-help-scope="workspace"/, "shared workspaces preserve contextual-help identity");
assert.match(actionToolbar, /action-toolbar-wrap-\$\{wrap\}/, "shared toolbars own responsive wrapping");
assert.match(actionToolbar, /data-help-scope="toolbar"/, "shared toolbars preserve contextual-help identity");
assert.match(contentGrid, /content-grid-collapse-\$\{collapseAt\}/, "shared grids make their collapse point explicit");
assert.match(formSection, /form-section-header/, "shared form sections own heading and action placement");
assert.match(dialogAnatomy, /dialog-actions-\$\{align\}/, "shared dialog anatomy owns footer action placement");
assert.match(iconRail, /<div className="icon-rail-scroll">\s*<nav className="icon-nav">/, "the module navigation has a dedicated scroll viewport");
assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1 1 auto;[^}]*overflow-y: auto;/, "only the middle rail region scrolls");
+88
View File
@@ -0,0 +1,88 @@
import type { HTMLAttributes, ReactNode } from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
export type ActionToolbarDensity = "compact" | "default";
export type ActionToolbarSurface = "plain" | "subtle";
export type ActionToolbarWrap = "responsive" | "always" | "never";
export type ActionToolbarProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: ReactNode;
label?: string;
density?: ActionToolbarDensity;
surface?: ActionToolbarSurface;
wrap?: ActionToolbarWrap;
};
export default function ActionToolbar({
children,
label,
density = "default",
surface = "plain",
wrap = "responsive",
className = "",
role,
"aria-label": ariaLabel,
interfaceId,
helpContextId,
helpModuleId,
helpTopicId,
...props
}: ActionToolbarProps) {
const { translateText } = usePlatformLanguage();
const translatedLabel = label || ariaLabel ? translateText(label ?? ariaLabel ?? "") : undefined;
return (
<div
{...props}
className={[
"action-toolbar",
`action-toolbar-density-${density}`,
`action-toolbar-surface-${surface}`,
`action-toolbar-wrap-${wrap}`,
className
].filter(Boolean).join(" ")}
role={role ?? (translatedLabel ? "toolbar" : undefined)}
aria-label={translatedLabel}
data-help-scope="toolbar"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
>
{children}
</div>
);
}
export type ToolbarGroupProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
align?: "start" | "center" | "end";
grow?: boolean;
};
export function ToolbarGroup({
children,
align = "start",
grow = false,
className = "",
...props
}: ToolbarGroupProps) {
return (
<div
{...props}
className={[
"action-toolbar-group",
`action-toolbar-group-${align}`,
grow ? "action-toolbar-group-grow" : "",
className
].filter(Boolean).join(" ")}
>
{children}
</div>
);
}
export function ToolbarSpacer({ className = "", ...props }: HTMLAttributes<HTMLSpanElement>) {
return <span {...props} className={["action-toolbar-spacer", className].filter(Boolean).join(" ")} aria-hidden="true" />;
}
+109
View File
@@ -0,0 +1,109 @@
import type { FormHTMLAttributes, HTMLAttributes, ReactNode } from "react";
export type ContentGridColumns = 1 | 2 | 3 | 4;
export type ContentGridGap = "compact" | "small" | "default" | "loose";
export type ContentGridCollapseAt = "narrow" | "workspace" | "standard" | "wide" | "never";
export type ContentGridProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
columns?: ContentGridColumns;
gap?: ContentGridGap;
collapseAt?: ContentGridCollapseAt;
align?: "start" | "stretch";
};
export default function ContentGrid({
children,
columns = 2,
gap = "default",
collapseAt = "workspace",
align = "start",
className = "",
...props
}: ContentGridProps) {
return (
<div
{...props}
className={[
"content-grid-layout",
`content-grid-columns-${columns}`,
`content-grid-gap-${gap}`,
`content-grid-collapse-${collapseAt}`,
`content-grid-align-${align}`,
className
].filter(Boolean).join(" ")}
>
{children}
</div>
);
}
export type FormGridProps = Omit<ContentGridProps, "align">;
export function FormGrid({
children,
columns = 1,
gap = "default",
collapseAt = "standard",
className = "",
...props
}: FormGridProps) {
return (
<ContentGrid
{...props}
columns={columns}
gap={gap}
collapseAt={collapseAt}
align="start"
className={["form-grid-layout", className].filter(Boolean).join(" ")}
>
{children}
</ContentGrid>
);
}
export type FormLayoutProps = FormHTMLAttributes<HTMLFormElement> & {
children: ReactNode;
columns?: ContentGridColumns;
gap?: ContentGridGap;
collapseAt?: ContentGridCollapseAt;
};
export function FormLayout({
children,
columns = 1,
gap = "default",
collapseAt = "standard",
className = "",
...props
}: FormLayoutProps) {
return (
<form
{...props}
className={[
"content-grid-layout",
"form-grid-layout",
`content-grid-columns-${columns}`,
`content-grid-gap-${gap}`,
`content-grid-collapse-${collapseAt}`,
"content-grid-align-start",
className
].filter(Boolean).join(" ")}
>
{children}
</form>
);
}
export type GridItemProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
span?: "one" | "two" | "full";
};
export function GridItem({ children, span = "one", className = "", ...props }: GridItemProps) {
return (
<div {...props} className={["content-grid-item", `content-grid-item-${span}`, className].filter(Boolean).join(" ")}>
{children}
</div>
);
}
@@ -1,3 +1,4 @@
import { FormGrid } from "./ContentGrid";
import { useEffect, useMemo, useState } from "react";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import type {
@@ -504,14 +505,14 @@ export default function CredentialEnvelopeManager({
</LoadingFrame>
</Card>
<Dialog
<Dialog variant="administration" size="wide"
open={Boolean(editing)}
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
helpContextId="access.credentials.editor"
helpModuleId="access"
onClose={closeEditor}
closeDisabled={saving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
className="adaptive-config-dialog"
footerClassName="button-row compact-actions"
footer={
<>
@@ -528,7 +529,7 @@ export default function CredentialEnvelopeManager({
<h3>Identity</h3>
<p>The name and type are visible; secret values are never returned by the API.</p>
</header>
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Name" helpContextId="access.credentials.field.name" helpModuleId="access">
<input data-help-context-id="access.credentials.field.name" data-help-module-id="access" value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
</FormField>
@@ -563,14 +564,14 @@ export default function CredentialEnvelopeManager({
label="Remove configured secret"
/>
)}
</div>
</FormGrid>
</section>
<section className="adaptive-config-section">
<header>
<h3>Availability</h3>
<p>Empty module or server lists mean every module or server allowed by scope.</p>
</header>
<div className="form-grid two">
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope." helpContextId="access.credentials.field.allowed-modules" helpModuleId="access">
<ReferenceMultiSelect
values={splitValues(draft.allowedModules)}
@@ -594,7 +595,7 @@ export default function CredentialEnvelopeManager({
</FormField>
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} helpContextId="access.credentials.field.inherit-to-lower-scopes" helpModuleId="access" onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} helpContextId="access.credentials.field.active" helpModuleId="access" onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
</div>
</FormGrid>
</section>
</div>
</Dialog>
+26 -3
View File
@@ -9,12 +9,23 @@ import {
type DialogStackId
} from "./dialogStack";
import type { PlatformInterfaceIdentityProps } from "../types";
import { DialogActions, type DialogActionAlignment } from "./DialogAnatomy";
export type DialogSize = "small" | "default" | "large" | "wide" | "full";
export type DialogVariant = "default" | "administration";
export type DialogBodyPadding = "none" | "compact" | "default";
export type DialogProps = PlatformInterfaceIdentityProps & {
open: boolean;
title: ReactNode;
children: ReactNode;
footer?: ReactNode;
description?: ReactNode;
notices?: ReactNode;
size?: DialogSize;
variant?: DialogVariant;
bodyPadding?: DialogBodyPadding;
footerAlignment?: DialogActionAlignment;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
closeLabel?: string;
@@ -42,6 +53,12 @@ export default function Dialog({
title,
children,
footer,
description,
notices,
size = "default",
variant = "default",
bodyPadding = "default",
footerAlignment = "end",
role = "dialog",
ariaDescribedBy,
closeLabel = "i18n:govoplan-core.close.bbfa773e",
@@ -83,6 +100,8 @@ export default function Dialog({
const renderedTitle = translateReactNode(title, translateText);
const renderedChildren = translateReactNode(children, translateText);
const renderedFooter = translateReactNode(footer, translateText);
const renderedDescription = translateReactNode(description, translateText);
const renderedNotices = translateReactNode(notices, translateText);
const translatedCloseLabel = translateText(closeLabel);
useEffect(() => {
@@ -134,7 +153,7 @@ export default function Dialog({
<section
ref={panelRef}
tabIndex={-1}
className={joinClasses("dialog-panel", className)}
className={joinClasses("dialog-panel", `dialog-panel-size-${size}`, `dialog-panel-${variant}`, className)}
style={panelStyle}
role={role}
aria-modal="true"
@@ -162,8 +181,12 @@ export default function Dialog({
</button>
)}
</div>
<div className={joinClasses("dialog-body", bodyClassName)}>{renderedChildren}</div>
{footer && <div className={joinClasses("dialog-footer", footerClassName)}>{renderedFooter}</div>}
<div className={joinClasses("dialog-body", `dialog-body-padding-${bodyPadding}`, bodyClassName)}>
{description && <div className="dialog-description">{renderedDescription}</div>}
{notices && <div className="dialog-notices">{renderedNotices}</div>}
{renderedChildren}
</div>
{footer && <div className={joinClasses("dialog-footer", footerClassName)}><DialogActions align={footerAlignment}>{renderedFooter}</DialogActions></div>}
</section>
</div>
);
+42
View File
@@ -0,0 +1,42 @@
import type { FormHTMLAttributes, HTMLAttributes, ReactNode } from "react";
export type DialogActionAlignment = "start" | "between" | "end";
export type DialogActionsProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
align?: DialogActionAlignment;
};
export function DialogActions({ children, align = "end", className = "", ...props }: DialogActionsProps) {
return (
<div {...props} className={["dialog-actions", `dialog-actions-${align}`, className].filter(Boolean).join(" ")}>
{children}
</div>
);
}
export type DialogFormProps = FormHTMLAttributes<HTMLFormElement> & {
children: ReactNode;
spacing?: "compact" | "default" | "loose";
};
export function DialogForm({ children, spacing = "default", className = "", ...props }: DialogFormProps) {
return (
<form {...props} className={["dialog-form-layout", `dialog-form-spacing-${spacing}`, className].filter(Boolean).join(" ")}>
{children}
</form>
);
}
export type DialogSectionProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
variant?: "plain" | "separated" | "inset";
};
export function DialogSection({ children, variant = "plain", className = "", ...props }: DialogSectionProps) {
return (
<div {...props} className={["dialog-section-layout", `dialog-section-${variant}`, className].filter(Boolean).join(" ")}>
{children}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { HTMLAttributes, ReactNode } from "react";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
export type FormSectionVariant = "plain" | "separated" | "panel";
export type FormSectionProps = HTMLAttributes<HTMLElement> & {
children: ReactNode;
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
variant?: FormSectionVariant;
contentClassName?: string;
};
export default function FormSection({
children,
title,
description,
actions,
variant = "plain",
className = "",
contentClassName = "",
...props
}: FormSectionProps) {
const { translateText } = usePlatformLanguage();
return (
<section {...props} className={["form-section-layout", `form-section-${variant}`, className].filter(Boolean).join(" ")}>
{(title || description || actions) && (
<header className="form-section-header">
<div className="form-section-heading-copy">
{title && <h3>{translateReactNode(title, translateText)}</h3>}
{description && <div className="form-section-description">{translateReactNode(description, translateText)}</div>}
</div>
{actions && <div className="form-section-actions">{translateReactNode(actions, translateText)}</div>}
</header>
)}
<div className={["form-section-content", contentClassName].filter(Boolean).join(" ")}>{children}</div>
</section>
);
}
@@ -1,3 +1,4 @@
import { FormGrid } from "./ContentGrid";
import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts";
export type ResourceAccessExplanationProps = {
@@ -23,12 +24,12 @@ export default function ResourceAccessExplanation({
return (
<>
<div className="form-grid compact responsive-form-grid">
<FormGrid columns={2} collapseAt="wide" className="">
<div><span className="form-label">i18n:govoplan-core.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-core.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-core.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-core.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
</FormGrid>
{explanation.provenance.length === 0 ? (
<p className="muted small-note">i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e</p>
) : (
+16 -14
View File
@@ -1,3 +1,4 @@
import { FormGrid } from "./ContentGrid";
import {
mergeAttributes,
Node,
@@ -36,6 +37,7 @@ import {
type MouseEvent
} from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
import Button from "./Button";
import Dialog from "./Dialog";
import FormField from "./FormField";
@@ -478,8 +480,8 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
<div className={rootClassName} style={editorStyle} role="group" aria-label={translatedAriaLabel}>
<div className="wysiwyg-editor-header">
{mode === "visual" && (
<div className="wysiwyg-editor-toolbar" role="toolbar" aria-label={translatedLabels.editor}>
<div className="wysiwyg-toolbar-group">
<ActionToolbar className="wysiwyg-editor-toolbar" label={translatedLabels.editor}>
<ToolbarGroup className="wysiwyg-toolbar-group">
<IconButton
variant="ghost"
className="wysiwyg-toolbar-button"
@@ -498,7 +500,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
onMouseDown={preserveSelection}
onClick={() => editor.chain().focus().redo().run()}
/>
</div>
</ToolbarGroup>
<label className="wysiwyg-block-style">
<span className="sr-only">{translatedLabels.blockStyle}</span>
<select
@@ -513,25 +515,25 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
<option value="heading-3">{translatedLabels.heading3}</option>
</select>
</label>
<div className="wysiwyg-toolbar-group">
<ToolbarGroup className="wysiwyg-toolbar-group">
<ToolbarButton active={toolbarState.bold} disabled={disabled} label={translatedLabels.bold} icon={<Bold />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBold().run()} />
<ToolbarButton active={toolbarState.italic} disabled={disabled} label={translatedLabels.italic} icon={<Italic />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleItalic().run()} />
<ToolbarButton active={toolbarState.underline} disabled={disabled} label={translatedLabels.underline} icon={<Underline />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleUnderline().run()} />
<ToolbarButton active={toolbarState.strike} disabled={disabled} label={translatedLabels.strike} icon={<Strikethrough />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleStrike().run()} />
<ToolbarButton active={toolbarState.code} disabled={disabled} label={translatedLabels.inlineCodeLabel} icon={<Code2 />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleCode().run()} />
</div>
<div className="wysiwyg-toolbar-group">
</ToolbarGroup>
<ToolbarGroup className="wysiwyg-toolbar-group">
<ToolbarButton active={toolbarState.bulletList} disabled={disabled} label={translatedLabels.bulletList} icon={<List />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBulletList().run()} />
<ToolbarButton active={toolbarState.numberedList} disabled={disabled} label={translatedLabels.numberedList} icon={<ListOrdered />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleOrderedList().run()} />
<ToolbarButton active={toolbarState.quote} disabled={disabled} label={translatedLabels.quote} icon={<Quote />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBlockquote().run()} />
</div>
<div className="wysiwyg-toolbar-group">
</ToolbarGroup>
<ToolbarGroup className="wysiwyg-toolbar-group">
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.insertLink} icon={<Link2 />} disabled={disabled} onMouseDown={preserveSelection} onClick={openLinkDialog} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.removeLink} icon={<Link2Off />} disabled={disabled || !toolbarState.link} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetLink().run()} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.insertImage} icon={<ImagePlus />} disabled={disabled} onMouseDown={preserveSelection} onClick={openImageDialog} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.clearFormatting} icon={<RemoveFormatting />} disabled={disabled} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()} />
</div>
</div>
</ToolbarGroup>
</ActionToolbar>
)}
{allowSourceMode && (
<SegmentedControl
@@ -579,7 +581,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
</>
)}
>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
{linkSelectionRef.current.from === linkSelectionRef.current.to && (
<FormField label={translatedLabels.linkText}>
<input value={linkText} onChange={(event) => setLinkText(event.target.value)} />
@@ -589,7 +591,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
<input value={linkUrl} inputMode="url" onChange={(event) => setLinkUrl(event.target.value)} />
</FormField>
{linkError && <p className="wysiwyg-editor-dialog-error" role="alert">{linkError}</p>}
</div>
</FormGrid>
</Dialog>
<Dialog
@@ -604,7 +606,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
</>
)}
>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label={translatedLabels.imageUrlLabel}>
<input value={imageUrl} inputMode="url" onChange={(event) => setImageUrl(event.target.value)} />
</FormField>
@@ -612,7 +614,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
<input value={imageAlt} onChange={(event) => setImageAlt(event.target.value)} />
</FormField>
{imageError && <p className="wysiwyg-editor-dialog-error" role="alert">{imageError}</p>}
</div>
</FormGrid>
</Dialog>
</div>
);
@@ -1,3 +1,4 @@
import { FormGrid } from "../ContentGrid";
import { useEffect, useRef, useState, type ReactNode } from "react";
import Button from "../Button";
import { CredentialFields } from "../CredentialPanel";
@@ -307,7 +308,7 @@ export default function MailServerSettingsPanel({
<div className="mail-server-settings-view">
{activeSection === "smtp" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<FormGrid columns={2} collapseAt="wide" className="mail-server-form-grid">
{showServerFields &&
<>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
@@ -324,7 +325,7 @@ export default function MailServerSettingsPanel({
savedPassword={smtpPasswordSaved}
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
}
</div>
</FormGrid>
{onTestSmtp &&
<div className="button-row compact-actions mail-server-actions">
<Button type="button" variant="primary" onClick={onTestSmtp} disabled={smtpActionsDisabled || busyAction === "smtp"} disabledReason={busyAction === "smtp" ? "SMTP connection testing is already running." : smtpActionsDisabled ? smtpActionDisabledReason : undefined}>{busyAction === "smtp" ? "i18n:govoplan-core.testing.15ccc832" : smtpTestLabel}</Button>
@@ -336,7 +337,7 @@ export default function MailServerSettingsPanel({
{activeSection === "imap" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<FormGrid columns={2} collapseAt="wide" className="mail-server-form-grid">
{showServerFields &&
<>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
@@ -353,7 +354,7 @@ export default function MailServerSettingsPanel({
savedPassword={imapPasswordSaved}
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
}
</div>
</FormGrid>
{onTestImap &&
<div className="button-row compact-actions mail-server-actions">
<Button type="button" variant="primary" onClick={onTestImap} disabled={imapActionsDisabled || busyAction === "imap"} disabledReason={busyAction === "imap" ? "IMAP connection testing is already running." : imapActionsDisabled ? imapActionDisabledReason : undefined}>{busyAction === "imap" ? "i18n:govoplan-core.testing.15ccc832" : imapTestLabel}</Button>
+3 -2
View File
@@ -1,3 +1,4 @@
import { FormLayout } from "../../components/ContentGrid";
import { useId, useState } from "react";
import type { ApiSettings, LoginResponse } from "../../types";
import { login } from "../../api/auth";
@@ -53,7 +54,7 @@ export default function LoginModal({
</>
}>
<form id={formId} className="form-grid" onSubmit={submit}>
<FormLayout columns={1} collapseAt="standard" id={formId} className="" onSubmit={submit}>
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormField label="i18n:govoplan-core.email.84add5b2">
@@ -62,7 +63,7 @@ export default function LoginModal({
<FormField label="i18n:govoplan-core.password.8be3c943">
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField>
</form>
</FormLayout>
</Dialog>);
}
@@ -1,3 +1,4 @@
import ContentGrid from "../../components/ContentGrid";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import PageLayout from "../../components/PageLayout";
@@ -17,7 +18,7 @@ export default function DashboardPage() {
<MetricCard label="Dashboard module" value="not installed" tone="neutral" detail="Core fallback is active." />
</div>
<div className="dashboard-grid">
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-core.installed_modules.32b3e799">
{modules.length === 0 ? <p className="muted">i18n:govoplan-core.no_feature_modules_are_active.e9c2d936</p> :
<dl className="detail-list">
@@ -33,7 +34,7 @@ export default function DashboardPage() {
<Card title="Home">
<p className="muted">This minimal core home is only shown while no dashboard WebUI module is available. Feature modules own their pages and can expose dashboard widgets once the dashboard module is installed.</p>
</Card>
</div>
</ContentGrid>
</PageLayout>);
}
+19 -18
View File
@@ -1,3 +1,4 @@
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
@@ -317,9 +318,9 @@ export default function SettingsPage({
</div>
{active === "profile" &&
<div className="dashboard-grid settings-dashboard-grid">
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-core.my_profile.2f3df0a9">
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label="i18n:govoplan-core.account_display_name.03ed8fc5" help="i18n:govoplan-core.this_global_name_is_shown_in_the_title_bar_and_f.1d46240e">
<input value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder={auth.user.email} />
</FormField>
@@ -340,7 +341,7 @@ export default function SettingsPage({
</Button>
</div>
{profileResult && <DismissibleAlert tone={profileResultTone} resetKey={profileResult} floating>{profileResult}</DismissibleAlert>}
</div>
</FormGrid>
</Card>
<Card title="i18n:govoplan-core.account_context.5bb0a918">
<dl className="detail-list compact-detail-list">
@@ -351,7 +352,7 @@ export default function SettingsPage({
</dl>
<p className="muted small-note">i18n:govoplan-core.email_changes_and_password_management_require_de.f7443b69</p>
</Card>
</div>
</ContentGrid>
}
{active === "mail-profiles" && MailProfileScopeManager &&
@@ -393,9 +394,9 @@ export default function SettingsPage({
}
{active === "interface" &&
<div className="dashboard-grid settings-dashboard-grid">
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<ToggleSwitch
label="i18n:govoplan-core.compact_tables.c755d9ee"
help="i18n:govoplan-core.prepared_ui_preference_for_denser_tables_the_cur.45698d83"
@@ -425,10 +426,10 @@ export default function SettingsPage({
</Button>
</div>
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
</div>
</FormGrid>
</Card>
<Card title="i18n:govoplan-core.theme_and_language.60a8cf59">
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label="i18n:govoplan-core.language.89b86ab0">
<select value={language} onChange={(event) => setLanguage(event.target.value)} disabled={selectableLanguages.length <= 1}>
{selectableLanguages.map((item) =>
@@ -455,15 +456,15 @@ export default function SettingsPage({
<div><dt>i18n:govoplan-core.available.7c62a142</dt><dd>{availableLanguages.map((item) => item.code.toUpperCase()).join(", ")}</dd></div>
<div><dt>i18n:govoplan-core.density.f9160c22</dt><dd>{compactTables ? "i18n:govoplan-core.compact_preview.3e06901d" : "i18n:govoplan-core.comfortable.2313707a"}</dd></div>
</dl>
</div>
</FormGrid>
</Card>
</div>
</ContentGrid>
}
{active === "workspace" &&
<div className="dashboard-grid settings-dashboard-grid">
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-core.campaign_workspace.c345580f">
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<ToggleSwitch
label="i18n:govoplan-core.sticky_section_sidebars.399c92d4"
help="i18n:govoplan-core.keeps_campaign_and_admin_section_navigation_in_v.f3602938"
@@ -488,7 +489,7 @@ export default function SettingsPage({
</Button>
</div>
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
</div>
</FormGrid>
</Card>
<Card title="i18n:govoplan-core.editor_behavior.99581bc1">
<div className="placeholder-stack">
@@ -498,13 +499,13 @@ export default function SettingsPage({
<span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span>
</div>
</Card>
</div>
</ContentGrid>
}
{active === "local-connection" &&
<div className="dashboard-grid settings-dashboard-grid">
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="i18n:govoplan-core.local_api_connection.bfb02921">
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
<FormField label="i18n:govoplan-core.api_base_url.1358fba4" help="i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7">
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
</FormField>
@@ -526,7 +527,7 @@ export default function SettingsPage({
</Button>
</div>
{testResult && <DismissibleAlert tone={testResultTone} resetKey={testResult} floating>{testResult}</DismissibleAlert>}
</div>
</FormGrid>
</Card>
<Card title="i18n:govoplan-core.session_state.b7a3d0f4">
<dl className="detail-list compact-detail-list">
@@ -536,7 +537,7 @@ export default function SettingsPage({
</dl>
<p className="muted small-note">i18n:govoplan-core.tenant_user_mail_server_and_policy_administratio.2f551271</p>
</Card>
</div>
</ContentGrid>
}
{activeContributedSection &&
+9
View File
@@ -49,6 +49,8 @@ export * from "./i18n/LanguageContext";
export { PermissionBoundary, ResourceAccessBoundary } from "./components/AccessBoundary";
export { default as ActionBlockerHint } from "./components/ActionBlockerHint";
export type { ActionBlockerLabels, ActionBlockerReason } from "./components/ActionBlockerHint";
export { default as ActionToolbar, ToolbarGroup, ToolbarSpacer } from "./components/ActionToolbar";
export type { ActionToolbarDensity, ActionToolbarProps, ActionToolbarSurface, ActionToolbarWrap, ToolbarGroupProps } from "./components/ActionToolbar";
export { default as AdvancedOptionsPanel } from "./components/AdvancedOptionsPanel";
export { default as AdminIconButton } from "./components/admin/AdminIconButton";
export type { AdminIconButtonProps } from "./components/admin/AdminIconButton";
@@ -85,6 +87,9 @@ export {
export type { CredentialEnvelopePayload, CredentialEnvelopeUpdatePayload } from "./api/credentials";
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
export { default as Dialog } from "./components/Dialog";
export type { DialogBodyPadding, DialogProps, DialogSize, DialogVariant } from "./components/Dialog";
export { DialogActions, DialogForm, DialogSection } from "./components/DialogAnatomy";
export type { DialogActionAlignment, DialogActionsProps, DialogFormProps, DialogSectionProps } from "./components/DialogAnatomy";
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
export { default as DismissibleAlert } from "./components/DismissibleAlert";
@@ -96,6 +101,10 @@ export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBloc
export { default as FileDropZone } from "./components/FileDropZone";
export type { FileDropZoneProps } from "./components/FileDropZone";
export { default as FormField } from "./components/FormField";
export { default as ContentGrid, FormGrid, FormLayout, GridItem } from "./components/ContentGrid";
export type { ContentGridCollapseAt, ContentGridColumns, ContentGridGap, ContentGridProps, FormGridProps, FormLayoutProps, GridItemProps } from "./components/ContentGrid";
export { default as FormSection } from "./components/FormSection";
export type { FormSectionProps, FormSectionVariant } from "./components/FormSection";
export { default as GuidedConfigDialog } from "./components/GuidedConfigDialog";
export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog";
export { default as GuidedReviewList } from "./components/GuidedReviewList";
+2 -25
View File
@@ -1050,24 +1050,10 @@
height: 17px;
}
.admin-form-grid {
display: grid;
gap: 14px;
}
.admin-form-grid > .wide {
grid-column: 1 / -1;
}
.admin-form-grid.two-columns,
.admin-assignment-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-form-grid.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-assignment-grid {
display: grid;
gap: 18px;
@@ -1111,15 +1097,13 @@
}
@media (max-width: 900px) {
.admin-form-grid.two-columns,
.admin-form-grid.three-columns,
.admin-assignment-grid,
.admin-permission-groups {
grid-template-columns: 1fr;
}
}
.guided-config-dialog.admin-dialog-wide {
.guided-config-dialog {
width: min(1120px, calc(100vw - 40px));
height: min(760px, calc(100vh - 40px));
}
@@ -1195,7 +1179,7 @@
min-width: 0;
}
.adaptive-config-dialog.admin-dialog-wide {
.adaptive-config-dialog {
width: min(1040px, calc(100vw - 40px));
}
@@ -3401,9 +3385,6 @@
font-size: 14px;
letter-spacing: .01em;
}
.form-grid.compact.mail-server-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mail-server-form-grid {
align-items: start;
}
@@ -3464,7 +3445,6 @@
}
@media (max-width: 720px) {
.credential-panel-grid { grid-template-columns: 1fr; }
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
.mail-server-segmented-control { width: 100%; }
}
@@ -3562,9 +3542,6 @@
.compact-detail-list {
gap: 8px;
}
.settings-dashboard-grid {
align-items: start;
}
.settings-list {
display: grid;
gap: 12px;
+39 -21
View File
@@ -86,6 +86,15 @@
box-shadow: var(--shadow-strong);
}
.dialog-panel-size-small { width: min(460px, calc(100vw - 40px)); }
.dialog-panel-size-default { width: min(560px, calc(100vw - 40px)); }
.dialog-panel-size-large { width: min(680px, calc(100vw - 40px)); }
.dialog-panel-size-wide { width: min(1040px, calc(100vw - 40px)); }
.dialog-panel-size-full { width: min(1440px, calc(100vw - 40px)); height: min(920px, calc(100vh - 40px)); }
.dialog-panel-administration > .dialog-header,
.dialog-panel-administration > .dialog-footer { background: var(--panel); }
.dialog-panel-administration > .dialog-footer { box-shadow: var(--shadow-footer); }
.dialog-header {
flex: 0 0 auto;
min-height: 58px;
@@ -135,10 +144,17 @@
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 20px;
color: var(--text);
}
.dialog-body-padding-none { padding: 0; }
.dialog-body-padding-compact { padding: 12px; }
.dialog-body-padding-default { padding: 20px; }
.dialog-description { margin-bottom: 16px; color: var(--muted); line-height: 1.5; }
.dialog-description > :first-child { margin-top: 0; }
.dialog-description > :last-child { margin-bottom: 0; }
.dialog-notices { display: grid; gap: 10px; margin-bottom: 16px; }
.dialog-footer {
flex: 0 0 auto;
display: flex;
@@ -149,6 +165,18 @@
background: var(--panel);
}
.dialog-actions { width: 100%; min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem; }
.dialog-actions-start { justify-content: flex-start; }
.dialog-actions-between { justify-content: space-between; }
.dialog-actions-end { justify-content: flex-end; }
.dialog-form-layout { min-width: 0; display: grid; }
.dialog-form-spacing-compact { gap: 10px; }
.dialog-form-spacing-default { gap: 16px; }
.dialog-form-spacing-loose { gap: 24px; }
.dialog-section-layout { min-width: 0; display: grid; gap: 12px; }
.dialog-section-separated { padding-top: 16px; border-top: var(--border-line); }
.dialog-section-inset { padding: 14px; border: var(--border-line); border-radius: var(--radius); background: var(--panel-soft); }
.confirm-dialog {
width: min(460px, calc(100vw - 40px));
}
@@ -164,24 +192,14 @@
line-height: 1.5;
}
.admin-dialog {
width: min(680px, calc(100vw - 40px));
}
.admin-dialog-wide {
width: min(1040px, calc(100vw - 40px));
}
.admin-dialog > .dialog-header {
padding: 16px 18px;
border-bottom: var(--border-line);
background: var(--panel);
}
.admin-dialog > .dialog-footer {
flex: 0 0 auto;
padding: 12px 18px;
border-top: var(--border-line);
background: var(--panel);
box-shadow: var(--shadow-footer);
@media (max-width: 600px) {
.dialog-backdrop { padding: 10px; }
.dialog-panel-size-small,
.dialog-panel-size-default,
.dialog-panel-size-large,
.dialog-panel-size-wide,
.dialog-panel-size-full { width: calc(100vw - 20px); max-height: calc(100vh - 20px); }
.dialog-header { padding-inline: 14px; }
.dialog-body-padding-default { padding: 16px 14px; }
.dialog-footer { padding-inline: 14px; }
}
+42 -11
View File
@@ -1,10 +1,29 @@
.form-grid { display: grid; gap: 18px; }
.form-grid.compact,
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.form-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.form-grid > .wide { grid-column: 1 / -1; }
.responsive-form-grid { align-items: start; }
.content-grid-layout { min-width: 0; display: grid; }
.content-grid-columns-1 { grid-template-columns: minmax(0, 1fr); }
.content-grid-columns-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.content-grid-columns-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.content-grid-columns-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.content-grid-gap-compact { gap: 10px; }
.content-grid-gap-small { gap: 14px; }
.content-grid-gap-default { gap: 18px; }
.content-grid-gap-loose { gap: 26px; }
.content-grid-align-start { align-items: start; }
.content-grid-align-stretch { align-items: stretch; }
.content-grid-item { min-width: 0; }
.content-grid-item-two { grid-column: span 2; }
.content-grid-item-full { grid-column: 1 / -1; }
.form-grid-layout > .wide { grid-column: 1 / -1; }
.form-section-layout { min-width: 0; display: grid; gap: 14px; }
.form-section-separated { padding-top: 18px; border-top: var(--border-line); }
.form-section-panel { padding: 18px; border: var(--border-line); border-radius: var(--radius); background: var(--panel); }
.form-section-header { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.form-section-heading-copy { min-width: 0; display: grid; gap: 5px; }
.form-section-heading-copy h3 { margin: 0; color: var(--text-strong); font-size: 1rem; }
.form-section-description { color: var(--muted); line-height: 1.45; }
.form-section-description > :first-child { margin-top: 0; }
.form-section-description > :last-child { margin-bottom: 0; }
.form-section-actions { flex: 0 0 auto; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.form-section-content { min-width: 0; display: grid; gap: 14px; }
.form-field { display: grid; gap: 7px; }
.form-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
.form-help { font-size: 12px; color: var(--muted); }
@@ -25,13 +44,25 @@ input[type="radio"]:focus-visible {
textarea { resize: vertical; }
@media (max-width: 1100px) {
.form-grid.compact.responsive-form-grid { grid-template-columns: 1fr; }
.content-grid-collapse-wide { grid-template-columns: minmax(0, 1fr); }
.content-grid-collapse-wide > .content-grid-item-two { grid-column: auto; }
}
@media (max-width: 980px) {
.form-grid.two,
.form-grid.three,
.form-grid.four { grid-template-columns: 1fr; }
.content-grid-collapse-standard { grid-template-columns: minmax(0, 1fr); }
.content-grid-collapse-standard > .content-grid-item-two { grid-column: auto; }
}
@media (max-width: 900px) {
.content-grid-collapse-workspace { grid-template-columns: minmax(0, 1fr); }
.content-grid-collapse-workspace > .content-grid-item-two { grid-column: auto; }
}
@media (max-width: 700px) {
.content-grid-collapse-narrow { grid-template-columns: minmax(0, 1fr); }
.content-grid-collapse-narrow > .content-grid-item-two { grid-column: auto; }
.form-section-header { flex-direction: column; }
.form-section-actions { width: 100%; }
}
.form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); }
input:disabled:not([type="checkbox"]):not([type="radio"]),
+17 -2
View File
@@ -80,6 +80,16 @@
.crumb { display: inline-flex; align-items: center; gap: 4px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
.ghost-button { border: 0; background: transparent; color: var(--muted); font-weight: 700; }
.action-toolbar { min-width: 0; display: flex; align-items: center; gap: 10px; }
.action-toolbar-density-compact { gap: 7px; }
.action-toolbar-surface-subtle { padding: 10px 12px; border: var(--border-line); border-radius: var(--radius); background: var(--panel-soft); }
.action-toolbar-wrap-always { flex-wrap: wrap; }
.action-toolbar-wrap-never { flex-wrap: nowrap; overflow-x: auto; }
.action-toolbar-group { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.action-toolbar-group-center { justify-content: center; }
.action-toolbar-group-end { justify-content: flex-end; margin-inline-start: auto; }
.action-toolbar-group-grow { flex: 1 1 auto; }
.action-toolbar-spacer { flex: 1 1 auto; min-width: 8px; }
.app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
.workspace-layout-primary,
@@ -214,7 +224,6 @@
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; }
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
.wizard-card { width: min(980px, 100%); background: var(--panel); border: var(--border-line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
@@ -271,10 +280,16 @@
.workspace-layout-split { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); }
.section-sidebar { display: none; }
.wizard-card { grid-template-columns: 1fr; }
.metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: 1fr; }
.concurrency-conflict-values { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.action-toolbar-wrap-responsive { align-items: stretch; flex-wrap: wrap; }
.action-toolbar-wrap-responsive > .action-toolbar-group { flex: 1 1 100%; }
.action-toolbar-wrap-responsive > .action-toolbar-group-end { margin-inline-start: 0; justify-content: flex-start; }
}
/* Layout additions: login and help dropdown */
.tenant-selector.disabled { opacity: .86; cursor: default; }
+80
View File
@@ -0,0 +1,80 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
import { renderToStaticMarkup } from "react-dom/server";
import ActionToolbar, { ToolbarGroup, ToolbarSpacer } from "../src/components/ActionToolbar";
import ContentGrid, { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid";
import Dialog from "../src/components/Dialog";
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
import FormSection from "../src/components/FormSection";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
const toolbarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<ActionToolbar label="Object actions" density="compact" surface="subtle" interfaceId="object.toolbar">
<ToolbarGroup grow>Filters</ToolbarGroup>
<ToolbarSpacer />
<ToolbarGroup align="end">Actions</ToolbarGroup>
</ActionToolbar>
</PlatformLanguageProvider>
);
assert(toolbarMarkup.includes("action-toolbar-wrap-responsive"), "toolbars use the responsive central wrapping policy by default");
assert(toolbarMarkup.includes('role="toolbar"'), "named toolbars expose toolbar semantics");
assert(toolbarMarkup.includes('aria-label="Object actions"'), "named toolbars expose their accessible name");
assert(toolbarMarkup.includes('data-help-scope="toolbar"'), "toolbar help scope is centralized");
assert(toolbarMarkup.includes("action-toolbar-group-grow"), "toolbar groups can own flexible space");
assert(toolbarMarkup.includes("action-toolbar-spacer"), "the standard action spacer is available");
const gridMarkup = renderToStaticMarkup(
<ContentGrid columns={3} gap="compact" collapseAt="wide">
<GridItem>A</GridItem>
<GridItem span="two">B</GridItem>
<GridItem span="full">C</GridItem>
</ContentGrid>
);
assert(gridMarkup.includes("content-grid-columns-3"), "content grids expose explicit column geometry");
assert(gridMarkup.includes("content-grid-collapse-wide"), "content grids expose explicit collapse ownership");
assert(gridMarkup.includes("content-grid-item-full"), "grid items can span the complete layout");
const formMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<FormSection title="Identity" description="Stable identifying values" actions={<button type="button">Reset</button>}>
<FormGrid columns={2}><label>Name<input /></label><label>Code<input /></label></FormGrid>
</FormSection>
</PlatformLanguageProvider>
);
assert(formMarkup.includes("form-section-header"), "form sections centralize heading and action placement");
assert(formMarkup.includes("form-grid-layout"), "forms compose the central content grid");
assert(formMarkup.includes("content-grid-columns-2"), "form columns remain explicit");
const formElementMarkup = renderToStaticMarkup(<FormLayout columns={2} id="object-form"><label>Value<input /></label></FormLayout>);
assert(formElementMarkup.startsWith('<form id="object-form"'), "form layouts preserve native form semantics");
assert(formElementMarkup.includes("form-grid-layout"), "native forms share the central grid geometry");
const dialogMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<Dialog
open
title="Edit object"
description="Review the values before saving."
notices={<div role="status">Draft</div>}
size="wide"
variant="administration"
footer={<><button type="button">Cancel</button><button type="submit">Save</button></>}
>
<DialogForm><DialogSection variant="inset">Fields</DialogSection></DialogForm>
</Dialog>
</PlatformLanguageProvider>
);
assert(dialogMarkup.includes("dialog-panel-size-wide"), "dialog size is owned by the central anatomy");
assert(dialogMarkup.includes("dialog-panel-administration"), "dialog variants are explicit");
assert(dialogMarkup.includes("dialog-description"), "dialog introductions use the central region");
assert(dialogMarkup.includes("dialog-notices"), "dialog notices use the central region");
assert(dialogMarkup.includes("dialog-actions dialog-actions-end"), "every dialog footer composes the central action layout");
assert(dialogMarkup.includes("dialog-form-layout"), "dialog forms use the central vertical flow");
assert(dialogMarkup.includes("dialog-section-inset"), "dialog sections use central grouping variants");
+5
View File
@@ -25,6 +25,7 @@
"tests/documentation-help-link.test.tsx",
"tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx",
"tests/layout-primitives.test.tsx",
"tests/mail-components.test.tsx",
"tests/metric-card.test.tsx",
"tests/page-layout.test.tsx",
@@ -52,6 +53,10 @@
"src/components/WorkspaceLayout.tsx",
"src/components/LoadingFrame.tsx",
"src/components/LoadingIndicator.tsx",
"src/components/ActionToolbar.tsx",
"src/components/ContentGrid.tsx",
"src/components/DialogAnatomy.tsx",
"src/components/FormSection.tsx",
"src/components/people/PeoplePicker.tsx",
"src/components/people/peoplePickerTypes.ts",
"src/components/ResourceAccessExplanation.tsx",