Add cross-module operational and interaction contracts

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent 4cb334c912
commit b65b48832b
23 changed files with 593 additions and 13 deletions
+2 -1
View File
@@ -26,12 +26,13 @@
},
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"prebuild": "npm run audit:i18n-structural",
"prebuild": "npm run audit:i18n-structural && npm run test:theme-contract",
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
"preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
"test:theme-contract": "node scripts/test-theme-contract.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "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/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
"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",
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const webuiRoot = resolve(import.meta.dirname, "..");
const repositoryRoot = resolve(webuiRoot, "..", "..");
const tokens = readFileSync(resolve(webuiRoot, "src/styles/tokens.css"), "utf8");
const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8");
const settings = readFileSync(resolve(webuiRoot, "src/features/settings/SettingsPage.tsx"), "utf8");
assert.match(tokens, /:root\[data-theme="dark"\]/, "dark token overrides are required");
assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme");
assert.match(app, /matchMedia\?\.\("\(prefers-color-scheme: dark\)"\)/, "system theme must follow OS changes");
assert.match(app, /dataset\.themePreference/, "the selected preference must remain inspectable");
for (const theme of ["system", "light", "dark"]) {
assert.match(settings, new RegExp(`value:\\s*"${theme}"`), `Settings must expose ${theme}`);
}
const representativeModules = [
"govoplan-campaign/webui/src/styles/campaign-workspace.css",
"govoplan-calendar/webui/src/styles/calendar.css",
"govoplan-files/webui/src/styles/file-manager.css",
"govoplan-mail/webui/src/styles/mail-profiles.css",
];
for (const relativePath of representativeModules) {
const css = readFileSync(resolve(repositoryRoot, relativePath), "utf8");
assert.match(css, /var\(--/, `${relativePath} must consume shared theme tokens`);
}
console.log("Theme contract passed for core and representative module surfaces.");
+7 -2
View File
@@ -1,6 +1,6 @@
import { useRef, useState, useEffect } from "react";
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu";
import LoginModal from "../features/auth/LoginModal";
@@ -37,6 +37,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
const projection = useEffectiveView();
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
const ViewSelector = viewsRuntime?.Selector ?? null;
const actingRuntime = usePlatformUiCapability<ActingContextRuntimeUiCapability>("access.actingContext");
const ActingContextSelector = actingRuntime?.Selector ?? null;
const searchRuntime = usePlatformUiCapability<SearchRuntimeUiCapability>("search.runtime");
const GlobalSearch = searchRuntime?.GlobalSearch ?? null;
const canUseGlobalSearch = Boolean(
@@ -60,7 +62,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
);
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
const showContextSelectors = Boolean(
auth && ((activeTenant && showTenantControl) || ViewSelector)
auth && ((activeTenant && showTenantControl) || ViewSelector || ActingContextSelector)
);
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
const notificationSummary = useSharedNotificationSummary(settings, {
@@ -206,6 +208,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
{ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
{ActingContextSelector &&
<ActingContextSelector settings={settings} auth={auth} onAuthChange={onAuthChange} />
}
</div>
}
</div>
+5
View File
@@ -33,6 +33,9 @@
.titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; }
.titlebar.has-global-search .titlebar-actions { grid-column: 3; }
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
.acting-context-selector { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: var(--muted); }
.acting-context-selector select { width: auto; max-width: 260px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-strong); font: inherit; font-weight: 700; padding: 5px 24px 5px 8px; }
.acting-context-selector select:hover, .acting-context-selector select:focus-visible { background: var(--titlebar-hover-bg); }
.tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
.block { display: block; }
@@ -265,6 +268,8 @@
.docs-outline { padding-top: 22px; }
.docs-sidebar-header { padding: 0 16px 16px; }
.docs-sidebar-header .section-title { padding: 0 6px 12px; }
.docs-version-selector { display: grid; gap: 5px; margin-top: 12px; color: var(--muted); font-size: 12px; font-weight: 700; }
.docs-version-selector select { width: 100%; min-height: 34px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--input-bg); color: var(--text); font: inherit; padding: 5px 8px; }
.docs-audience-toggle { width: 100%; }
.docs-tree { display: grid; gap: 2px; padding: 0 10px 24px; }
.docs-tree-node { min-width: 0; }
+11
View File
@@ -92,11 +92,22 @@ export type PrincipalContext = {
api_key_id?: string | null;
session_id?: string | null;
service_account_id?: string | null;
acting_assignment_id?: string | null;
acting_for_account_id?: string | null;
email?: string | null;
display_name?: string | null;
};
export type ActingContextSelectorProps = {
settings: ApiSettings;
auth: AuthInfo;
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
};
export type ActingContextRuntimeUiCapability = {
Selector: ComponentType<ActingContextSelectorProps>;
};
export type AuthInfo = {
user: AuthUser;
// Backwards-compatible active tenant alias returned by older/newer APIs.