Skip to content

Commit 5715744

Browse files
authored
fix(control-ui): apply seamColor bootstrap config (#93699)
1 parent 527f20f commit 5715744

5 files changed

Lines changed: 157 additions & 1 deletion

File tree

src/gateway/control-ui-contract.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type ControlUiBootstrapConfig = {
2020
embedSandbox?: ControlUiEmbedSandboxMode;
2121
allowExternalEmbedUrls?: boolean;
2222
chatMessageMaxWidth?: string;
23+
seamColor?: string;
2324
/** Resolved `agents.defaults.timeFormat`; "auto" keeps the browser locale default. */
2425
timeFormat?: "auto" | "12" | "24";
2526
};

src/gateway/control-ui.http.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ describe("handleControlUiHttpRequest", () => {
4747
assistantAgentId: string;
4848
localMediaPreviewRoots?: string[];
4949
chatMessageMaxWidth?: string;
50+
seamColor?: string;
5051
timeFormat?: "auto" | "12" | "24";
5152
};
5253
}
@@ -823,7 +824,10 @@ describe("handleControlUiHttpRequest", () => {
823824
config: {
824825
agents: { defaults: { workspace: tmp, timeFormat: "24" } },
825826
gateway: { controlUi: { chatMessageMaxWidth: "min(1280px, 82%)" } },
826-
ui: { assistant: { name: "</script><script>alert(1)//", avatar: "</script>.png" } },
827+
ui: {
828+
seamColor: "#1A2b3C",
829+
assistant: { name: "</script><script>alert(1)//", avatar: "</script>.png" },
830+
},
827831
},
828832
},
829833
);
@@ -834,6 +838,7 @@ describe("handleControlUiHttpRequest", () => {
834838
expect(parsed.assistantAvatar).toBe("/avatar/main");
835839
expect(parsed.assistantAgentId).toBe("main");
836840
expect(parsed.chatMessageMaxWidth).toBe("min(1280px, 82%)");
841+
expect(parsed.seamColor).toBe("#1A2b3C");
837842
expect(parsed.timeFormat).toBe("24");
838843
expect(Array.isArray(parsed.localMediaPreviewRoots)).toBe(true);
839844
},

src/gateway/control-ui.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,7 @@ export async function handleControlUiHttpRequest(
995995
: "scripts",
996996
allowExternalEmbedUrls: config?.gateway?.controlUi?.allowExternalEmbedUrls === true,
997997
chatMessageMaxWidth: config?.gateway?.controlUi?.chatMessageMaxWidth,
998+
seamColor: config?.ui?.seamColor,
998999
timeFormat: config?.agents?.defaults?.timeFormat,
9991000
} satisfies ControlUiBootstrapConfig);
10001001
return true;

ui/src/ui/controllers/control-ui-bootstrap.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function requireFetchCall(fetchMock: ReturnType<typeof vi.fn>, index = 0) {
1616
describe("loadControlUiBootstrapConfig", () => {
1717
afterEach(() => {
1818
setUiTimeFormatPreference("auto");
19+
document.documentElement.removeAttribute("style");
1920
});
2021

2122
it("threads agents.defaults.timeFormat into the UI hour-cycle preference", async () => {
@@ -108,6 +109,101 @@ describe("loadControlUiBootstrapConfig", () => {
108109
vi.unstubAllGlobals();
109110
});
110111

112+
it("applies configured seamColor to Control UI accent variables", async () => {
113+
const fetchMock = vi.fn().mockResolvedValue({
114+
ok: true,
115+
json: async () => ({
116+
basePath: "",
117+
assistantName: "Main",
118+
assistantAvatar: "M",
119+
assistantAgentId: "main",
120+
seamColor: "#1A2b3C",
121+
}),
122+
});
123+
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
124+
125+
const state = {
126+
basePath: "",
127+
assistantName: "Assistant",
128+
assistantAvatar: null,
129+
assistantAvatarSource: null,
130+
assistantAvatarStatus: null,
131+
assistantAvatarReason: null,
132+
assistantAgentId: null,
133+
localMediaPreviewRoots: [],
134+
embedSandboxMode: "scripts" as const,
135+
allowExternalEmbedUrls: false,
136+
chatMessageMaxWidth: null,
137+
serverVersion: null,
138+
};
139+
140+
await loadControlUiBootstrapConfig(state);
141+
142+
const rootStyle = document.documentElement.style;
143+
expect(rootStyle.getPropertyValue("--accent")).toBe("#1A2b3C");
144+
expect(rootStyle.getPropertyValue("--ring")).toBe("#1A2b3C");
145+
expect(rootStyle.getPropertyValue("--primary")).toBe("#1A2b3C");
146+
expect(rootStyle.getPropertyValue("--accent-hover")).toBe(
147+
"color-mix(in srgb, var(--accent) 82%, white 18%)",
148+
);
149+
expect(rootStyle.getPropertyValue("--accent-subtle")).toBe(
150+
"color-mix(in srgb, var(--accent) 16%, transparent)",
151+
);
152+
153+
vi.unstubAllGlobals();
154+
});
155+
156+
it("removes server seamColor variables when bootstrap color is missing or invalid", async () => {
157+
const fetchMock = vi
158+
.fn()
159+
.mockResolvedValueOnce({
160+
ok: true,
161+
json: async () => ({
162+
basePath: "",
163+
assistantName: "Main",
164+
assistantAvatar: "M",
165+
assistantAgentId: "main",
166+
seamColor: "00aaee",
167+
}),
168+
})
169+
.mockResolvedValueOnce({
170+
ok: true,
171+
json: async () => ({
172+
basePath: "",
173+
assistantName: "Main",
174+
assistantAvatar: "M",
175+
assistantAgentId: "main",
176+
seamColor: "lobster",
177+
}),
178+
});
179+
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
180+
181+
const state = {
182+
basePath: "",
183+
assistantName: "Assistant",
184+
assistantAvatar: null,
185+
assistantAvatarSource: null,
186+
assistantAvatarStatus: null,
187+
assistantAvatarReason: null,
188+
assistantAgentId: null,
189+
localMediaPreviewRoots: [],
190+
embedSandboxMode: "scripts" as const,
191+
allowExternalEmbedUrls: false,
192+
chatMessageMaxWidth: null,
193+
serverVersion: null,
194+
};
195+
196+
await loadControlUiBootstrapConfig(state);
197+
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#00aaee");
198+
199+
await loadControlUiBootstrapConfig(state);
200+
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("");
201+
expect(document.documentElement.style.getPropertyValue("--ring")).toBe("");
202+
expect(document.documentElement.style.getPropertyValue("--focus-ring")).toBe("");
203+
204+
vi.unstubAllGlobals();
205+
});
206+
111207
it("can refresh runtime bootstrap settings without clobbering session identity", async () => {
112208
const fetchMock = vi.fn().mockResolvedValue({
113209
ok: true,

ui/src/ui/controllers/control-ui-bootstrap.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ import { normalizeAgentId, parseAgentSessionKey } from "../session-key.ts";
1212
import { loadLocalAssistantIdentity } from "../storage.ts";
1313
import { normalizeOptionalString } from "../string-coerce.ts";
1414

15+
const SEAM_COLOR_CSS_VARIABLES = [
16+
"--ring",
17+
"--accent",
18+
"--accent-hover",
19+
"--accent-muted",
20+
"--accent-subtle",
21+
"--accent-glow",
22+
"--primary",
23+
"--focus",
24+
"--focus-ring",
25+
"--focus-glow",
26+
] as const;
27+
1528
export type ControlUiBootstrapState = {
1629
basePath: string;
1730
assistantName: string;
@@ -31,6 +44,45 @@ export type ControlUiBootstrapState = {
3144
password?: string | null;
3245
};
3346

47+
function normalizeSeamColor(value: unknown): string | null {
48+
if (typeof value !== "string") {
49+
return null;
50+
}
51+
const hex = value.trim().replace(/^#/, "");
52+
return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;
53+
}
54+
55+
function applyControlUiSeamColor(value: unknown) {
56+
if (typeof document === "undefined") {
57+
return;
58+
}
59+
const root = document.documentElement;
60+
const color = normalizeSeamColor(value);
61+
if (!color) {
62+
for (const property of SEAM_COLOR_CSS_VARIABLES) {
63+
root.style.removeProperty(property);
64+
}
65+
return;
66+
}
67+
68+
root.style.setProperty("--ring", color);
69+
root.style.setProperty("--accent", color);
70+
root.style.setProperty("--accent-hover", "color-mix(in srgb, var(--accent) 82%, white 18%)");
71+
root.style.setProperty("--accent-muted", color);
72+
root.style.setProperty("--accent-subtle", "color-mix(in srgb, var(--accent) 16%, transparent)");
73+
root.style.setProperty("--accent-glow", "color-mix(in srgb, var(--accent) 30%, transparent)");
74+
root.style.setProperty("--primary", color);
75+
root.style.setProperty("--focus", "color-mix(in srgb, var(--ring) 22%, transparent)");
76+
root.style.setProperty(
77+
"--focus-ring",
78+
"0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent)",
79+
);
80+
root.style.setProperty(
81+
"--focus-glow",
82+
"0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow)",
83+
);
84+
}
85+
3486
function resolveActiveAgentId(state: ControlUiBootstrapState): string | null {
3587
const sessionAgentId = parseAgentSessionKey(state.sessionKey)?.agentId;
3688
if (sessionAgentId) {
@@ -136,6 +188,7 @@ export async function loadControlUiBootstrapConfig(
136188
typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim()
137189
? parsed.chatMessageMaxWidth
138190
: null;
191+
applyControlUiSeamColor(parsed.seamColor);
139192
setUiTimeFormatPreference(parsed.timeFormat);
140193
} catch {
141194
// Ignore bootstrap failures; UI will update identity after connecting.

0 commit comments

Comments
 (0)