diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 45453204be35..fdfe1c550d39 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -1118,6 +1118,26 @@ describe("talk.config handler", () => { }, ); + it("prefers the user accent over the operator seam color", async () => { + markTalkOwnerCold("tts"); + const runtimeConfig = createTalkConfig("healthy-talk-key"); + mocks.getSpeechProvider.mockReturnValue({ id: "acme" }); + mocks.readConfigFileSnapshot.mockResolvedValue({ + config: { ...runtimeConfig, ui: { seamColor: "#123456", prefs: { accent: "#52c99a" } } }, + }); + const respond = vi.fn(); + + await callTalkHandler("talk.config", { + params: {}, + client: { connect: { scopes: ["operator.read"] } }, + respond, + context: { getRuntimeConfig: () => runtimeConfig }, + }); + + expect(respond.mock.calls[0]?.[0]).toBe(true); + expect(respond.mock.calls[0]?.[1]?.config?.ui).toEqual({ seamColor: "#52c99a" }); + }); + it("projects the runtime realtime transport when source config is invalid", async () => { mocks.readConfigFileSnapshot.mockResolvedValue({ path: "/tmp/openclaw.json", diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 00f384f39992..225510631018 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -802,7 +802,9 @@ export const talkHandlers: GatewayRequestHandlers = { configPayload.session = { mainKey: sessionMainKey }; } - const seamColor = snapshot.config.ui?.seamColor; + // User accent wins over the operator seam color, matching Control UI + // precedence (ui.prefs.accent -> ui.seamColor -> theme default). + const seamColor = snapshot.config.ui?.prefs?.accent ?? snapshot.config.ui?.seamColor; if (typeof seamColor === "string") { configPayload.ui = { seamColor }; } diff --git a/ui/src/lib/widget-theme.ts b/ui/src/lib/widget-theme.ts index cd15d299faac..35158b8e8408 100644 --- a/ui/src/lib/widget-theme.ts +++ b/ui/src/lib/widget-theme.ts @@ -94,6 +94,8 @@ export function installWidgetThemeObserver(): void { } }).observe(root, { attributes: true, - attributeFilter: ["data-theme", "data-theme-mode"], + // "style" carries the accent override (inline custom properties on ); + // without it a user accent change would only reach frames incidentally. + attributeFilter: ["data-theme", "data-theme-mode", "style"], }); } diff --git a/ui/src/pages/chat/components/widget-theme.test.ts b/ui/src/pages/chat/components/widget-theme.test.ts index d001bc5b4503..1c4081a77c15 100644 --- a/ui/src/pages/chat/components/widget-theme.test.ts +++ b/ui/src/pages/chat/components/widget-theme.test.ts @@ -130,7 +130,7 @@ describe("widget theme bridge", () => { document.documentElement, { attributes: true, - attributeFilter: ["data-theme", "data-theme-mode"], + attributeFilter: ["data-theme", "data-theme-mode", "style"], }, ); FakeMutationObserver.instances[0]?.trigger({ @@ -139,5 +139,12 @@ describe("widget theme bridge", () => { expect(chatPost).toHaveBeenCalledOnce(); expect(boardPost).toHaveBeenCalledOnce(); expect(unrelatedPost).not.toHaveBeenCalled(); + // Accent overrides land as inline style mutations on . + FakeMutationObserver.instances[0]?.trigger({ + attributeName: "style", + } as MutationRecord); + expect(chatPost).toHaveBeenCalledTimes(2); + expect(boardPost).toHaveBeenCalledTimes(2); + expect(unrelatedPost).not.toHaveBeenCalled(); }); });