Add governed module and interface controls

This commit is contained in:
2026-08-04 05:20:47 +02:00
parent 5bc7d748f8
commit d6e7c8b0b1
27 changed files with 1700 additions and 74 deletions
+5 -1
View File
@@ -1,5 +1,5 @@
import type { ApiSettings } from "../types";
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
import type { PlatformInterfaceCatalog, PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
import { apiFetch } from "./client";
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
@@ -50,3 +50,7 @@ export async function fetchPlatformStatus(settings: ApiSettings): Promise<Platfo
export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> {
return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions");
}
export async function fetchPlatformInterfaceCatalog(settings: ApiSettings): Promise<PlatformInterfaceCatalog> {
return apiFetch<PlatformInterfaceCatalog>(settings, "/api/v1/platform/interface-catalog");
}
+4 -3
View File
@@ -1,12 +1,13 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import DisabledActionTooltip from "./DisabledActionTooltip";
import type { PlatformInterfaceIdentityProps } from "../types";
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & PlatformInterfaceIdentityProps & {
variant?: "primary" | "secondary" | "ghost" | "danger";
disabledReason?: ReactNode;
};
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, ...props }: ButtonProps) {
const button = <button className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) {
const button = <button data-interface-id={interfaceId} data-help-topic-id={helpTopicId} className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
}
+8 -7
View File
@@ -1,8 +1,9 @@
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
import useOutsideDismiss from "../hooks/useOutsideDismiss";
import type { PlatformInterfaceIdentityProps } from "../types";
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & PlatformInterfaceIdentityProps & {
value: string;
onChange: (value: string) => void;
min?: string;
@@ -49,7 +50,7 @@ function combineDateTime(date: string, time: string): string {
return `${date || dateString(new Date())}T${time || "00:00"}`;
}
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpTopicId, ...props }: BaseProps) {
const selectedDate = parseDate(value);
const [open, setOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
@@ -93,7 +94,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
}
return (
<div ref={rootRef} className={`date-field ${className}`.trim()}>
<div ref={rootRef} className={`date-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<input
{...props}
ref={inputRef}
@@ -145,7 +146,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
}
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", ...props }: BaseProps) {
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpTopicId, ...props }: BaseProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const input = inputRef.current;
@@ -158,7 +159,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
}, [value, min, max]);
return (
<div className={`time-field ${className}`.trim()}>
<div className={`time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<input
{...props}
ref={inputRef}
@@ -174,7 +175,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
}
export function DateTimeField({ value, onChange, min, max, disabled, className = "", ...props }: BaseProps) {
export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpTopicId, ...props }: BaseProps) {
const parts = datePartsFromDateTime(value);
const minParts = datePartsFromDateTime(min || "");
const maxParts = datePartsFromDateTime(max || "");
@@ -188,7 +189,7 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
}
return (
<div className={`date-time-field ${className}`.trim()}>
<div className={`date-time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<DateField
{...props}
value={parts.date}
+10 -2
View File
@@ -3,12 +3,20 @@ import FieldLabel from "./help/FieldLabel";
import type { DocumentationHelpReference } from "./help/documentationHelp";
import { helpForFieldLabel } from "../utils/fieldHelp";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
export default function FormField({ label, help, documentation, children }: { label: ReactNode; help?: ReactNode; documentation?: DocumentationHelpReference; children: ReactNode }) {
type FormFieldProps = PlatformInterfaceIdentityProps & {
label: ReactNode;
help?: ReactNode;
documentation?: DocumentationHelpReference;
children: ReactNode;
};
export default function FormField({ label, help, documentation, children, interfaceId, helpTopicId }: FormFieldProps) {
const { translateText } = usePlatformLanguage();
const renderedLabel = typeof label === "string" ? translateText(label) : label;
return (
<label className="form-field">
<label className="form-field" data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel>
{children}
</label>
+1 -1
View File
@@ -43,7 +43,7 @@ export default class ModuleLoadBoundary extends Component<
if (this.state.error) {
return (
<div className="content-pad module-load-error">
<DismissibleAlert tone="danger" dismissible={false}>
<DismissibleAlert tone="danger" compact resetKey={`${this.props.resetKey}:${this.state.error.message}`}>
<p>i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf</p>
<Button type="button" onClick={() => window.location.reload()}>
i18n:govoplan-core.reload.cce71553
+7 -2
View File
@@ -9,6 +9,7 @@ import {
} from "react";
import { ChevronDown, Search } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
export type SearchableSelectOption = {
value: string;
@@ -27,7 +28,7 @@ export type SearchableSelectCreateCustomOption = (
value: string
) => SearchableSelectOption | null;
export type SearchableSelectProps = {
export type SearchableSelectProps = PlatformInterfaceIdentityProps & {
id?: string;
value: string;
onChange: (
@@ -95,7 +96,9 @@ export default function SearchableSelect({
minQueryLength = 0,
searchLimit = 50,
debounceMs = 200,
className = ""
className = "",
interfaceId,
helpTopicId
}: SearchableSelectProps) {
const { translateText } = usePlatformLanguage();
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -294,6 +297,8 @@ export default function SearchableSelect({
<div
ref={rootRef}
className={rootClassName}
data-interface-id={interfaceId}
data-help-topic-id={helpTopicId}
onBlur={closeOnFocusLeave}
>
<div className="searchable-select-control">
+4 -3
View File
@@ -2,8 +2,9 @@ import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel";
import { helpForFieldLabel } from "../utils/fieldHelp";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
type ToggleSwitchProps = {
type ToggleSwitchProps = PlatformInterfaceIdentityProps & {
label: ReactNode;
activeLabel?: ReactNode;
inactiveLabel?: ReactNode;
@@ -13,7 +14,7 @@ type ToggleSwitchProps = {
help?: ReactNode;
};
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpTopicId }: ToggleSwitchProps) {
const { translateText } = usePlatformLanguage();
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
const renderedLabel = typeof label === "string" ? translateText(label) : label;
@@ -21,7 +22,7 @@ export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checke
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<input
className="toggle-switch-input"
type="checkbox"
@@ -1,6 +1,7 @@
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import type { PlatformInterfaceIdentityProps } from "../../types";
import { createPortal } from "react-dom";
import Button from "../Button";
import {
@@ -12,7 +13,7 @@ import {
type MailboxAddress } from
"../../utils/emailAddresses";
type EmailAddressInputProps = {
type EmailAddressInputProps = PlatformInterfaceIdentityProps & {
value: MailboxAddress[];
onChange?: (value: MailboxAddress[]) => void;
onAddressAdded?: (address: MailboxAddress) => void;
@@ -43,7 +44,9 @@ export default function EmailAddressInput({
emailPlaceholder = "email@example.org",
emptyText = "i18n:govoplan-core.no_address_added_yet.809c4247",
compact = false,
showAddButton
showAddButton,
interfaceId,
helpTopicId
}: EmailAddressInputProps) {
const { translateText } = usePlatformLanguage();
const inputId = useId();
@@ -199,7 +202,7 @@ export default function EmailAddressInput({
) : null;
return (
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
+26 -17
View File
@@ -66,21 +66,29 @@ export default function IconRail({
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
</div>
{!compact &&
<>
<nav className="icon-nav">
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to);
const renderedLabel = translateText(label);
return (
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}>
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>);
})}
</nav>
{!compact && (
<>
<div className="icon-rail-scroll">
<nav className="icon-nav">
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to);
const renderedLabel = translateText(label);
return (
<NavLink
key={to}
to={target}
className={`icon-nav-item ${active ? "active" : ""}`}
title={renderedLabel}
onClick={(event) => handleNavClick(event, target)}
>
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>
);
})}
</nav>
</div>
<div className="icon-rail-bottom">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
<Settings size={20} />
@@ -97,8 +105,9 @@ export default function IconRail({
</button>
</div>
</>
}
</aside>);
)}
</aside>
);
}
+37 -20
View File
@@ -1,5 +1,5 @@
import { useRef, useState, useEffect } from "react";
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
import { Bell, Check, LogOut, Settings, TriangleAlert, UserCircle, WifiOff } from "lucide-react";
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu";
@@ -75,6 +75,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
0 :
Math.max(0, Number(notificationSummary?.unread) || 0);
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
const titlebarState = !backendReachable ? "offline" : maintenanceMode?.enabled ? "maintenance" : null;
const titlebarStateLabel = titlebarState === "offline"
? "System not reachable / offline!"
: translateText("i18n:govoplan-core.maintenance_mode_enabled.4fb4a37d");
useEffect(() => {
function onPointerDown(event: MouseEvent) {
@@ -148,27 +152,34 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
}
return (
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}`}>
{!backendReachable ?
<div
className="backend-offline-topbar-alert"
role="status"
aria-live="polite"
title="System not reachable / offline!">
System not reachable / offline!
</div> :
maintenanceMode?.enabled &&
<button
type="button"
className="maintenance-topbar-link"
title={maintenanceMode.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
onClick={openMaintenanceSettings}>
{translateText("i18n:govoplan-core.maintenance_mode_enabled.4fb4a37d")}
</button>
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}${titlebarState ? ` is-${titlebarState}` : ""}`}>
{titlebarState &&
<div className="titlebar-status-pattern" aria-hidden="true">
{Array.from({ length: 10 }, (_, index) => <span key={index}>{titlebarStateLabel}</span>)}
</div>
}
<div className="titlebar-leading">
{titlebarState === "offline" &&
<div
className="backend-offline-topbar-alert"
role="status"
aria-live="polite"
aria-label={titlebarStateLabel}
title={titlebarStateLabel}>
<WifiOff size={18} aria-hidden="true" />
</div>
}
{titlebarState === "maintenance" &&
<button
type="button"
className="maintenance-topbar-link"
aria-label={translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
onClick={openMaintenanceSettings}>
<TriangleAlert size={18} aria-hidden="true" />
</button>
}
{auth && showContextSelectors &&
<div className="titlebar-context-selectors">
{activeTenant && showTenantControl &&
@@ -237,7 +248,13 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
}
<div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
<button
className="account-pill"
aria-label={displayUserName}
aria-haspopup="menu"
aria-expanded={accountOpen}
title={displayUserName}
onClick={() => setAccountOpen(!accountOpen)}>
<UserCircle size={22} />
<span>{displayUserName}</span>
<span className="tenant-caret"></span>
+2
View File
@@ -2235,7 +2235,9 @@
}
.module-load-error .alert {
width: min(640px, 100%);
max-width: 640px;
margin: 0;
}
.module-load-error .alert-message {
+99 -5
View File
@@ -1,7 +1,7 @@
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; }
.icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
.icon-rail.expanded { width: 208px; align-items: stretch; }
.icon-rail-header { width: 100%; min-height: 63px; display: flex; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
.icon-rail-header { width: 100%; min-height: 63px; display: flex; flex: 0 0 auto; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
.brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; }
.brand-mark::after { position: absolute;
top: 9px;
@@ -16,6 +16,7 @@
.icon-rail-toggle:hover,
.icon-rail-toggle:focus-visible { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
.icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.icon-rail-scroll { width: 100%; min-width: 0; min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--rail-text-muted) transparent; scrollbar-width: thin; }
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
.icon-nav-item svg,
@@ -27,6 +28,12 @@
.icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
.titlebar.is-maintenance { background: var(--warning-bg); border-bottom-color: var(--warning-border-soft); }
.titlebar.is-offline { background: var(--danger-bg); border-bottom-color: var(--danger-border-deep); }
.titlebar > :not(.titlebar-status-pattern) { position: relative; z-index: 1; }
.titlebar-status-pattern { position: absolute; inset: 0; display: flex; align-items: center; gap: 34px; overflow: hidden; padding: 0 14px; pointer-events: none; white-space: nowrap; }
.titlebar-status-pattern span { flex: 0 0 auto; color: var(--warning-text); font-size: 11px; font-weight: 800; opacity: .16; }
.titlebar.is-offline .titlebar-status-pattern span { color: var(--danger-text); opacity: .18; }
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) minmax(190px, min(360px, 28vw)) minmax(0, 1fr); }
.titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; }
.titlebar-global-search { grid-column: 2; position: relative; width: 100%; min-width: 0; height: 34px; }
@@ -50,10 +57,10 @@
.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%); }
.account-pill { color: var(--text); }
.maintenance-topbar-link,
.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border-radius: 6px; min-height: 32px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; white-space: nowrap; }
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
.backend-offline-topbar-alert { width: 34px; height: 34px; flex: 0 0 auto; box-sizing: border-box; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; padding: 0; font: inherit; box-shadow: 0 1px 2px var(--hover-tint); }
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--surface); color: var(--warning-text); cursor: pointer; }
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--surface); color: var(--danger-text); }
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
.language-menu { min-width: 210px; }
@@ -322,7 +329,7 @@
/* Side rail: settings lives as the bottom utility entry. */
.icon-rail-bottom {
width: 100%;
margin-top: auto;
flex: 0 0 auto;
padding: 12px 0 20px;
}
.icon-rail-bottom .icon-nav-item {
@@ -331,3 +338,90 @@
.icon-rail-bottom .icon-rail-toggle {
border-top: 1px solid var(--rail-bg-active);
}
@media (max-width: 600px) {
.app-main {
grid-template-rows: 104px 51px minmax(0, 1fr);
}
.titlebar,
.titlebar.has-global-search {
grid-template-columns: 34px minmax(0, 1fr);
grid-template-rows: 42px 42px;
column-gap: 6px;
row-gap: 4px;
padding: 6px 8px;
}
.titlebar-leading {
grid-column: 1 / -1;
grid-row: 1;
overflow: hidden;
}
.titlebar-context-selectors {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
gap: 8px;
scrollbar-width: thin;
}
.titlebar-global-search {
grid-column: 1;
grid-row: 2;
}
.titlebar-actions,
.titlebar.has-global-search .titlebar-actions {
grid-column: 2;
grid-row: 2;
gap: 2px;
}
.titlebar:not(.has-global-search) .titlebar-actions {
grid-column: 1 / -1;
}
.language-menu-button {
min-width: 40px;
padding-inline: 4px;
}
.titlebar-actions > .context-menu-wrap:not(.language-menu-wrap) > .titlebar-link {
width: 34px;
height: 34px;
justify-content: center;
padding: 0;
font-size: 0;
}
.account-pill {
width: 34px;
height: 34px;
justify-content: center;
padding: 0;
}
.account-pill span {
display: none;
}
.breadcrumb-bar {
min-width: 0;
padding-inline: 12px;
overflow: hidden;
}
.breadcrumbs {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: thin;
}
.content-pad {
padding: 18px 14px;
}
}
+34
View File
@@ -385,6 +385,13 @@ export type PlatformPublicRouteContribution = {
export type PlatformUiCapabilities = Record<string, unknown>;
export type PlatformInterfaceIdentityProps = {
/** Stable control-plane identity; use a module-namespaced value. */
interfaceId?: string;
/** Optional stable documentation/help topic associated with the control. */
helpTopicId?: string;
};
export type PlatformTranslationDictionary = Record<string, string>;
export type PlatformTranslations = Record<string, PlatformTranslationDictionary>;
@@ -1050,6 +1057,32 @@ export type PlatformPublicModuleInfo = {
>;
};
export type PlatformInterfaceDeclarationInfo = {
key: string;
id: string;
module_id: string;
kind: string;
label?: string | null;
path?: string | null;
required_all: string[];
required_any: string[];
metadata: Record<string, unknown>;
};
export type PlatformModuleInterfaceCatalog = {
contract_version: string;
module_id: string;
module_version: string;
digest: string;
counts: Record<string, number>;
declarations: PlatformInterfaceDeclarationInfo[];
};
export type PlatformInterfaceCatalog = {
contract_version: string;
modules: PlatformModuleInterfaceCatalog[];
};
export type PlatformModuleInfo = {
id: string;
name: string;
@@ -1083,6 +1116,7 @@ export type PlatformModuleInfo = {
behavior: Record<string, unknown>;
}>;
runtime_ui_capabilities?: string[];
interface_catalog?: Omit<PlatformModuleInterfaceCatalog, "declarations">;
nav: Array<{
path: string;
label: string;
+4
View File
@@ -146,6 +146,8 @@ export const adminReadScopes = [
"admin:api_keys:read",
"admin:settings:read",
"admin:policies:read",
"admin:module:read",
"admin:module:write",
"mail:profile:read",
"mail_servers:read",
"audit:read",
@@ -163,6 +165,8 @@ export const adminReadScopes = [
"access:system_setting:read",
"access:system_credential:read",
"access:credential:read",
"access:service_account:read",
"approvals:workspace:admin",
"access:governance:read",
"views:definition:read",
"views:assignment:read",