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.