mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(control-ui): apply seamColor bootstrap config (#93699)
This commit is contained in:
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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: "</script><script>alert(1)//", avatar: "</script>.png" } },
|
||||
ui: {
|
||||
seamColor: "#1A2b3C",
|
||||
assistant: { name: "</script><script>alert(1)//", avatar: "</script>.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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@ function requireFetchCall(fetchMock: ReturnType<typeof vi.fn>, 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user