diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index f9f673462536..173be60eae86 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -20,6 +20,7 @@ export type ControlUiBootstrapConfig = { embedSandbox?: ControlUiEmbedSandboxMode; allowExternalEmbedUrls?: boolean; chatMessageMaxWidth?: string; + seamColor?: string; /** Resolved `agents.defaults.timeFormat`; "auto" keeps the browser locale default. */ timeFormat?: "auto" | "12" | "24"; }; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index dcfd21b8b685..55ed0cc19228 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -47,6 +47,7 @@ describe("handleControlUiHttpRequest", () => { assistantAgentId: string; localMediaPreviewRoots?: string[]; chatMessageMaxWidth?: string; + seamColor?: string; timeFormat?: "auto" | "12" | "24"; }; } @@ -823,7 +824,10 @@ describe("handleControlUiHttpRequest", () => { config: { agents: { defaults: { workspace: tmp, timeFormat: "24" } }, gateway: { controlUi: { chatMessageMaxWidth: "min(1280px, 82%)" } }, - ui: { assistant: { name: ".png" } }, + ui: { + seamColor: "#1A2b3C", + assistant: { name: ".png" }, + }, }, }, ); @@ -834,6 +838,7 @@ describe("handleControlUiHttpRequest", () => { expect(parsed.assistantAvatar).toBe("/avatar/main"); expect(parsed.assistantAgentId).toBe("main"); expect(parsed.chatMessageMaxWidth).toBe("min(1280px, 82%)"); + expect(parsed.seamColor).toBe("#1A2b3C"); expect(parsed.timeFormat).toBe("24"); expect(Array.isArray(parsed.localMediaPreviewRoots)).toBe(true); }, diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 4766e15b343f..a344190d657b 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -995,6 +995,7 @@ export async function handleControlUiHttpRequest( : "scripts", allowExternalEmbedUrls: config?.gateway?.controlUi?.allowExternalEmbedUrls === true, chatMessageMaxWidth: config?.gateway?.controlUi?.chatMessageMaxWidth, + seamColor: config?.ui?.seamColor, timeFormat: config?.agents?.defaults?.timeFormat, } satisfies ControlUiBootstrapConfig); return true; diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index cb78b43fc770..edc7749b2966 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -16,6 +16,7 @@ function requireFetchCall(fetchMock: ReturnType, index = 0) { describe("loadControlUiBootstrapConfig", () => { afterEach(() => { setUiTimeFormatPreference("auto"); + document.documentElement.removeAttribute("style"); }); it("threads agents.defaults.timeFormat into the UI hour-cycle preference", async () => { @@ -108,6 +109,101 @@ describe("loadControlUiBootstrapConfig", () => { vi.unstubAllGlobals(); }); + it("applies configured seamColor to Control UI accent variables", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Main", + assistantAvatar: "M", + assistantAgentId: "main", + seamColor: "#1A2b3C", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAvatarSource: null, + assistantAvatarStatus: null, + assistantAvatarReason: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + chatMessageMaxWidth: null, + serverVersion: null, + }; + + await loadControlUiBootstrapConfig(state); + + const rootStyle = document.documentElement.style; + expect(rootStyle.getPropertyValue("--accent")).toBe("#1A2b3C"); + expect(rootStyle.getPropertyValue("--ring")).toBe("#1A2b3C"); + expect(rootStyle.getPropertyValue("--primary")).toBe("#1A2b3C"); + expect(rootStyle.getPropertyValue("--accent-hover")).toBe( + "color-mix(in srgb, var(--accent) 82%, white 18%)", + ); + expect(rootStyle.getPropertyValue("--accent-subtle")).toBe( + "color-mix(in srgb, var(--accent) 16%, transparent)", + ); + + vi.unstubAllGlobals(); + }); + + it("removes server seamColor variables when bootstrap color is missing or invalid", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Main", + assistantAvatar: "M", + assistantAgentId: "main", + seamColor: "00aaee", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Main", + assistantAvatar: "M", + assistantAgentId: "main", + seamColor: "lobster", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAvatarSource: null, + assistantAvatarStatus: null, + assistantAvatarReason: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + chatMessageMaxWidth: null, + serverVersion: null, + }; + + await loadControlUiBootstrapConfig(state); + expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#00aaee"); + + await loadControlUiBootstrapConfig(state); + expect(document.documentElement.style.getPropertyValue("--accent")).toBe(""); + expect(document.documentElement.style.getPropertyValue("--ring")).toBe(""); + expect(document.documentElement.style.getPropertyValue("--focus-ring")).toBe(""); + + vi.unstubAllGlobals(); + }); + it("can refresh runtime bootstrap settings without clobbering session identity", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index 2d275bbe542e..98da82f13709 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -12,6 +12,19 @@ import { normalizeAgentId, parseAgentSessionKey } from "../session-key.ts"; import { loadLocalAssistantIdentity } from "../storage.ts"; import { normalizeOptionalString } from "../string-coerce.ts"; +const SEAM_COLOR_CSS_VARIABLES = [ + "--ring", + "--accent", + "--accent-hover", + "--accent-muted", + "--accent-subtle", + "--accent-glow", + "--primary", + "--focus", + "--focus-ring", + "--focus-glow", +] as const; + export type ControlUiBootstrapState = { basePath: string; assistantName: string; @@ -31,6 +44,45 @@ export type ControlUiBootstrapState = { password?: string | null; }; +function normalizeSeamColor(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const hex = value.trim().replace(/^#/, ""); + return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; +} + +function applyControlUiSeamColor(value: unknown) { + if (typeof document === "undefined") { + return; + } + const root = document.documentElement; + const color = normalizeSeamColor(value); + if (!color) { + for (const property of SEAM_COLOR_CSS_VARIABLES) { + root.style.removeProperty(property); + } + return; + } + + root.style.setProperty("--ring", color); + root.style.setProperty("--accent", color); + root.style.setProperty("--accent-hover", "color-mix(in srgb, var(--accent) 82%, white 18%)"); + root.style.setProperty("--accent-muted", color); + root.style.setProperty("--accent-subtle", "color-mix(in srgb, var(--accent) 16%, transparent)"); + root.style.setProperty("--accent-glow", "color-mix(in srgb, var(--accent) 30%, transparent)"); + root.style.setProperty("--primary", color); + root.style.setProperty("--focus", "color-mix(in srgb, var(--ring) 22%, transparent)"); + root.style.setProperty( + "--focus-ring", + "0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent)", + ); + root.style.setProperty( + "--focus-glow", + "0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)", + ); +} + function resolveActiveAgentId(state: ControlUiBootstrapState): string | null { const sessionAgentId = parseAgentSessionKey(state.sessionKey)?.agentId; if (sessionAgentId) { @@ -136,6 +188,7 @@ export async function loadControlUiBootstrapConfig( typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim() ? parsed.chatMessageMaxWidth : null; + applyControlUiSeamColor(parsed.seamColor); setUiTimeFormatPreference(parsed.timeFormat); } catch { // Ignore bootstrap failures; UI will update identity after connecting.