fix: keep custom themes stable during reloads (#153001)

* fix: keep custom themes stable during reloads

* fix(config): complete value-tree import migration
This commit is contained in:
Peter Steinberger 2026-09-19 09:45:16 -07:00 • committed by GitHub
parent 68df653df9
commit 2ba076dc63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 776 additions and 101 deletions

View file

@ -76,6 +76,14 @@ and may include `font-sans` and `font-mono`. Use CSS color values such as hex,
`rgb()`, `hsl()`, or `oklch()`. Font families describe locally available fonts;
definitions cannot load external stylesheets or resources.
Use consistent CSS separators: `rgb(20 30 40 / 50%)` or
`rgba(20, 30, 40, 0.5)`. Modern functions such as `oklch()` use spaces between
components and `/` before opacity. Font lists use comma-separated family names;
quote names containing punctuation or beginning with a digit, such as
`"123 Font", monospace`. Also quote names containing CSS keywords, such as
`"Foo serif"`. Malformed colors and unbalanced font quotes are rejected before
the theme is saved.
This example creates and activates a dark theme in one call:
```json

View file

@ -34,10 +34,33 @@ describe("portable theme definition", () => {
{ background: "#000;display:none" },
{ background: "var(--other-theme)" },
{ background: "rgb()" },
{ background: "rgb(1 2 3 .5)" },
{ background: "rgb(1, 2 3)" },
{ background: "rgb(1 2, 3)" },
{ background: "rgb(1, 2, 3 / .5)" },
{ background: "rgb(1%, 2, 3%)" },
{ background: "rgb(1. 2 3)" },
{ background: "rgb(1\u00a02\u00a03)" },
{ background: "hsl(180, 40, 50)" },
{ background: "hsl(180 40% 50% .5)" },
{ background: "oklch(50%, 0.2, 180)" },
{ background: "lab(50%, 20, 10)" },
{ background: "color(srgb\u00a00 0 0)" },
{ background: "red/* hidden */" },
{ "font-sans": "monospace; background: url(https://example.invalid)" },
{ "font-sans": "var(--font-body)" },
])("rejects executable or dependent CSS values %j", (palette) => {
{ "font-sans": "'unterminated" },
{ "font-sans": "Roboto,,monospace" },
{ "font-sans": "123Font" },
{ "font-sans": "Foo.Bar" },
{ "font-sans": "-1font" },
{ "font-sans": "serif Foo" },
{ "font-sans": "Foo serif" },
{ "font-sans": "Foo inherit" },
{ "font-sans": "default Foo" },
{ "font-sans": "default" },
{ "font-sans": "-webkit-body Foo" },
])("rejects unsafe or malformed CSS values %j", (palette) => {
expect(
parseThemeDefinition(
createThemeDefinitionFixture({ dark: createThemePaletteFixture(palette) }),
@ -45,6 +68,43 @@ describe("portable theme definition", () => {
).toBeNull();
});
it.each([
"rgb(1 2 3)",
"rgb(1e2 2 3)",
"rgb(1% 2 3% / 50%)",
"rgba(1, 2, 3, .5)",
"rgb(1%, 2%, 3%, 50%)",
"hsl(180 40 50 / .5)",
"hsla(0.5turn, 40%, 50%, .5)",
"lab(50% -20 10 / .5)",
"lch(50% 20 180deg)",
"oklab(50% -.2 .1 / 50%)",
"oklch(50% 0.2 180)",
"color(display-p3 .1 .2 .3 / .5)",
])("preserves supported color syntax: %s", (background) => {
expect(
normalizeThemeDefinition(
createThemeDefinitionFixture({ dark: createThemePaletteFixture({ background }) }),
).dark?.background,
).toBe(background);
});
it.each([
"JetBrains Mono, monospace",
"'A,B', monospace",
"\"A'B\", 'C\"D'",
'"123 Font", monospace',
"'serif Foo', 'Foo serif', 'Foo inherit', 'default Foo', 'default', 'inherit'",
"--font, Foo_Bar",
'""',
])("preserves font family names: %s", (font) => {
expect(
normalizeThemeDefinition(
createThemeDefinitionFixture({ dark: createThemePaletteFixture({ "font-sans": font }) }),
).dark?.["font-sans"],
).toBe(font);
});
it("rejects missing modes, incomplete palettes, unknown properties, and oversized stored values", () => {
const { background: _background, ...incomplete } = createThemePaletteFixture();
expect(() => normalizeThemeDefinition({ name: "Empty", description: "No colors" })).toThrow(

View file

@ -163,24 +163,68 @@ function requireText(value: unknown, label: string, maxLength: number): string {
return value.trim();
}
const NUMBER = "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
const NUMBER = "[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:e[+-]?\\d+)?";
const COMPONENT = `${NUMBER}%?`;
const HUE = `${NUMBER}(?:deg|grad|rad|turn)?`;
const COMPONENT_SEPARATOR = "(?:\\s*,\\s*|\\s+)";
const LEGACY_COLOR_FUNCTION = new RegExp(
`^(?:(?:rgb|rgba)\\( *(?:${NUMBER} *, *${NUMBER} *, *${NUMBER}|${NUMBER}% *, *${NUMBER}% *, *${NUMBER}%)|(?:hsl|hsla)\\( *${HUE} *, *${NUMBER}% *, *${NUMBER}%)(?: *, *${COMPONENT})? *\\)$`,
"i",
);
const COLOR_FUNCTION = new RegExp(
`^(?:(?:rgb|rgba|oklab|lab)\\(\\s*${COMPONENT}${COMPONENT_SEPARATOR}${COMPONENT}${COMPONENT_SEPARATOR}${COMPONENT}|(?:hsl|hsla)\\(\\s*${HUE}${COMPONENT_SEPARATOR}${COMPONENT}${COMPONENT_SEPARATOR}${COMPONENT}|(?:oklch|lch)\\(\\s*${COMPONENT}${COMPONENT_SEPARATOR}${COMPONENT}${COMPONENT_SEPARATOR}${HUE})(?:(?:\\s*[,/]\\s*|\\s+)${COMPONENT})?\\s*\\)$`,
`^(?:(?:rgb|rgba|oklab|lab)\\( *${COMPONENT} +${COMPONENT} +${COMPONENT}|(?:hsl|hsla)\\( *${HUE} +${COMPONENT} +${COMPONENT}|(?:oklch|lch)\\( *${COMPONENT} +${COMPONENT} +${HUE})(?: */ *${COMPONENT})? *\\)$`,
"i",
);
const COLOR_SPACE_FUNCTION = new RegExp(
`^color\\(\\s*(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz|xyz-d50|xyz-d65)\\s+${COMPONENT}\\s+${COMPONENT}\\s+${COMPONENT}(?:\\s*/\\s*${COMPONENT})?\\s*\\)$`,
`^color\\( *(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz|xyz-d50|xyz-d65) +${COMPONENT} +${COMPONENT} +${COMPONENT}(?: */ *${COMPONENT})? *\\)$`,
"i",
);
const FONT_IDENTIFIER = "(?:--[a-z0-9_-]*|-?[a-z_][a-z0-9_-]*)";
const FONT_FAMILY = `(?:"[a-z0-9 ,'._-]*"|'[a-z0-9 ,"._-]*'|${FONT_IDENTIFIER}(?: +${FONT_IDENTIFIER})*)`;
const FONT_FAMILY_LIST = new RegExp(`^${FONT_FAMILY}(?: *, *${FONT_FAMILY})*$`, "i");
const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
const GENERIC_FONT_FAMILIES = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"emoji",
"math",
"fangsong",
"-webkit-body",
]);
function isFontFamilyList(value: string): boolean {
if (!FONT_FAMILY_LIST.test(value)) {
return false;
}
// Quoted names may contain commas or match reserved keywords.
return value
.replace(/"[^"]*"|'[^']*'/g, "")
.split(",")
.every((family) => {
const normalized = family.trim().toLowerCase();
const words = normalized.split(/ +/);
return words.every(
(word) =>
!CSS_WIDE_KEYWORDS.has(word) &&
word !== "default" &&
(words.length === 1 || !GENERIC_FONT_FAMILIES.has(word)),
);
});
}
function requireColor(value: unknown, label: string): string {
const color = requireText(value, label, THEME_TOKEN_MAX_LENGTH);
if (
!/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) &&
!/^(?:transparent|black|white)$/i.test(color) &&
!LEGACY_COLOR_FUNCTION.test(color) &&
!COLOR_FUNCTION.test(color) &&
!COLOR_SPACE_FUNCTION.test(color)
) {
@ -204,7 +248,7 @@ function normalizePalette(value: unknown, mode: ThemeColorMode): ThemePalette {
continue;
}
const font = requireText(palette[key], `theme.${mode}.${key}`, THEME_TOKEN_MAX_LENGTH);
if (!/^[a-z0-9 ,'"._-]+$/i.test(font)) {
if (!isFontFamilyList(font)) {
throw new Error(`theme.${mode}.${key} must contain only font family names`);
}
result[key] = font;

View file

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js";
import type { ErrorShape } from "../../../packages/gateway-protocol/src/index.js";
import type { ThemeCatalogEntry } from "../../../packages/gateway-protocol/src/theme.js";
import { createDeferred } from "../../../test/helpers/promise.js";
import {
createThemeDefinitionFixture,
createThemePaletteFixture,
@ -13,7 +14,7 @@ import { closeOpenClawStateDatabaseAsync } from "../../state/openclaw-state-db-c
import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js";
import * as userPreferences from "../../state/user-preferences.js";
import { getUserPreferences, setUserPreferences } from "../../state/user-preferences.js";
import { ensureProfileForEmail } from "../../state/user-profiles.js";
import { ensureProfileForEmail, linkEmail } from "../../state/user-profiles.js";
import {
createOpenClawTestState,
type OpenClawTestState,
@ -146,6 +147,31 @@ function changePreferencesAfterSnapshot(entries: Record<string, unknown>) {
);
}
async function withConcurrentPreferenceSnapshots<T>(count: number, run: () => Promise<T>) {
const readPreferences = userPreferences.getCanonicalUserPreferences;
const release = createDeferred();
let remaining = count;
const read = vi
.spyOn(userPreferences, "getCanonicalUserPreferences")
.mockImplementation(async (...args) => {
const snapshot = await readPreferences(...args);
if (remaining > 0) {
remaining -= 1;
if (remaining === 0) {
release.resolve();
}
await release.promise;
}
return snapshot;
});
try {
return await run();
} finally {
release.resolve();
read.mockRestore();
}
}
describe("theme RPC", () => {
it("lists descriptive choices and inspects a plugin definition without exposing palettes in list entries", async () => {
pluginThemes.push(pluginTheme());
@ -275,15 +301,23 @@ describe("theme RPC", () => {
it.each([
{ background: "url(https://example.test/collect)" },
{ background: "rgb(1 2 3 .5)" },
{ background: "oklch(50%, 0.2, 180)" },
{ "font-sans": "sans-serif; background: red" },
])("rejects unsafe theme values without importing or selecting: %j", async (palette) => {
const definition = createThemeDefinitionFixture();
definition.dark = { ...definition.dark!, ...palette };
expect(await invoke("themes.import", { id: "unsafe", definition, apply: true })).toMatchObject({
ok: false,
});
expect(getUserPreferences(requesterProfileId)).toEqual({});
});
{ "font-sans": "'unterminated" },
])(
"rejects unsafe or malformed theme values without importing or selecting: %j",
async (palette) => {
const definition = createThemeDefinitionFixture();
definition.dark = { ...definition.dark!, ...palette };
expect(
await invoke("themes.import", { id: "unsafe", definition, apply: true }),
).toMatchObject({
ok: false,
});
expect(getUserPreferences(requesterProfileId)).toEqual({});
},
);
it("clears theme overrides against Gateway defaults while retaining saved custom themes", async () => {
const definition = createThemeDefinitionFixture();
@ -466,6 +500,212 @@ describe("theme RPC", () => {
});
});
it("preserves independent concurrent imports and atomically settles competing selections", async () => {
const expected: Record<string, unknown> = { "chat.showThinking": true };
expect(setUserPreferences(requesterProfileId, expected).ok).toBe(true);
for (let round = 0; round < 4; round += 1) {
Object.assign(expected, { "ui.theme": "claw", "ui.themeMode": "system" });
expect(
setUserPreferences(requesterProfileId, { "ui.theme": "claw", "ui.themeMode": "system" }).ok,
).toBe(true);
const ids = Array.from({ length: 4 }, (_, index) => `independent-${round}-${index}`);
const definitions = ids.map((name) => createThemeDefinitionFixture({ name }));
const imports = await withConcurrentPreferenceSnapshots(ids.length, () =>
Promise.all(
ids.map((id, index) => invoke("themes.import", { id, definition: definitions[index] })),
),
);
for (const [index, result] of imports.entries()) {
expect(result).toMatchObject({ ok: true, payload: { application: "saved" } });
expected[`ui.themeDefinition.${ids[index]}`] = definitions[index];
}
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
const choices = [
...[0, 1].map((index) => ({
method: "themes.import" as const,
params: {
id: `applied-${round}-${index}`,
definition: createThemeDefinitionFixture({ name: `Applied ${round}-${index}` }),
apply: true,
mode: "dark",
},
selectedId: `user/applied-${round}-${index}`,
})),
...["rose", "tide"].map((id) => ({
method: "themes.set" as const,
params: { id, mode: "dark" },
selectedId: id,
})),
];
const contenders = [...choices.slice(round), ...choices.slice(0, round)];
const selections = await withConcurrentPreferenceSnapshots(contenders.length, () =>
Promise.all(contenders.map(({ method, params }) => invoke(method, params))),
);
const winners = selections.flatMap((result, index) => (result.ok ? [index] : []));
expect(winners).toHaveLength(1);
const winnerIndex = expectDefined(winners[0], "winning theme mutation index");
const winner = expectDefined(contenders[winnerIndex], "winning theme mutation");
expected["ui.theme"] = winner.selectedId;
expected["ui.themeMode"] = "dark";
if (winner.method === "themes.import") {
expected[`ui.themeDefinition.${winner.params.id}`] = winner.params.definition;
}
for (const [index, result] of selections.entries()) {
expect(result).toMatchObject(
index === winnerIndex
? {
ok: true,
payload: { current: { id: winner.selectedId, mode: "dark" }, application: "saved" },
}
: { ok: false, error: { message: expect.stringContaining("Appearance changed") } },
);
}
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
expect(getUserPreferences(otherProfileId)).toEqual({});
expect(await invoke("themes.list")).toMatchObject({
ok: true,
payload: {
current: { id: winner.selectedId, mode: "dark" },
themes: expect.arrayContaining(
ids.map((id) => expect.objectContaining({ id: `user/${id}` })),
),
},
});
expect(await invoke("themes.get")).toMatchObject({
ok: true,
payload: {
current: { id: winner.selectedId, mode: "dark" },
theme: { id: winner.selectedId },
},
});
}
});
it("reads and writes through a merged profile alias and notifies both identities only", async () => {
const unrelatedId = ensureProfileForEmail("unrelated@example.test").id;
linkEmail("requester@example.test", otherProfileId);
const clients = [
client(requesterProfileId),
{ ...client(otherProfileId), connId: "canonical-browser" },
{ ...client(unrelatedId), connId: "unrelated-browser" },
];
const broadcastToConnIds = vi.fn();
const definition = createThemeDefinitionFixture();
expect(
await invoke(
"themes.import",
{ id: "merged", definition, apply: true, mode: "dark" },
{
context: {
broadcastToConnIds,
getClientConnIds: (filter) =>
new Set(
clients.filter((entry) => !filter || filter(entry)).map((entry) => entry.connId!),
),
},
},
),
).toMatchObject({
ok: true,
payload: { current: { id: "user/merged" }, application: "saved" },
});
expect(getUserPreferences(requesterProfileId)).toEqual({});
expect(getUserPreferences(otherProfileId)).toEqual({
"ui.themeDefinition.merged": definition,
"ui.theme": "user/merged",
"ui.themeMode": "dark",
});
expect(getUserPreferences(unrelatedId)).toEqual({});
for (const profileId of [requesterProfileId, otherProfileId]) {
expect(await invoke("themes.get", {}, { client: client(profileId) })).toMatchObject({
ok: true,
payload: { current: { id: "user/merged", mode: "dark" }, definition },
});
}
expect(broadcastToConnIds).toHaveBeenCalledExactlyOnceWith(
"users.prefs.changed",
{
profileId: otherProfileId,
keys: ["ui.themeDefinition.merged", "ui.theme", "ui.themeMode"],
},
new Set(["requester-browser", "canonical-browser"]),
);
});
it("does not redirect a prepared import when its profile merges before the write", async () => {
expect(setUserPreferences(requesterProfileId, { "ui.theme": "claw" }).ok).toBe(true);
const targetPreferences = { "ui.theme": "tide", "ui.themeMode": "light" };
expect(setUserPreferences(otherProfileId, targetPreferences).ok).toBe(true);
const readPreferences = userPreferences.getCanonicalUserPreferences;
vi.spyOn(userPreferences, "getCanonicalUserPreferences").mockImplementationOnce(
async (...args) => {
const snapshot = await readPreferences(...args);
linkEmail("requester@example.test", otherProfileId);
return snapshot;
},
);
const broadcastToConnIds = vi.fn();
expect(
await invoke(
"themes.import",
{ id: "stale", definition: createThemeDefinitionFixture(), apply: true },
{
context: { broadcastToConnIds, getClientConnIds: () => new Set(["requester-browser"]) },
},
),
).toMatchObject({ ok: false, error: { message: expect.stringContaining("profile changed") } });
expect(getUserPreferences(requesterProfileId)).toEqual({});
expect(getUserPreferences(otherProfileId)).toEqual(targetPreferences);
expect(broadcastToConnIds).not.toHaveBeenCalled();
});
it("rolls back an import at the profile key limit while allowing replacement at capacity", async () => {
const expected: Record<string, unknown> = {
...Object.fromEntries(
Array.from({ length: 125 }, (_, index) => [`retained-${index}`, index]),
),
"ui.theme": "claw",
"ui.themeMode": "dark",
};
const entries = Object.entries(expected);
for (let offset = 0; offset < entries.length; offset += 32) {
expect(
setUserPreferences(
requesterProfileId,
Object.fromEntries(entries.slice(offset, offset + 32)),
).ok,
).toBe(true);
}
const broadcastToConnIds = vi.fn();
const options = {
context: { broadcastToConnIds, getClientConnIds: () => new Set(["requester-browser"]) },
};
const definition = createThemeDefinitionFixture();
expect(
await invoke("themes.import", { id: "last", definition, apply: true, mode: "dark" }, options),
).toMatchObject({ ok: true });
Object.assign(expected, { "ui.themeDefinition.last": definition, "ui.theme": "user/last" });
expect(Object.keys(getUserPreferences(requesterProfileId))).toHaveLength(128);
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
expect(
await invoke("themes.import", { id: "overflow", definition, apply: true }, options),
).toMatchObject({
ok: false,
error: { message: expect.stringContaining("profile-key-limit") },
});
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
expect(broadcastToConnIds).toHaveBeenCalledTimes(1);
const replacement = createThemeDefinitionFixture({ name: "Updated at capacity" });
expect(
await invoke("themes.import", { id: "last", definition: replacement, apply: true }, options),
).toMatchObject({ ok: true });
expected["ui.themeDefinition.last"] = replacement;
expect(getUserPreferences(requesterProfileId)).toEqual(expected);
expect(getUserPreferences(otherProfileId)).toEqual({});
expect(broadcastToConnIds).toHaveBeenCalledTimes(2);
});
it("retains a missing plugin selection and resumes it when the catalog is republished", async () => {
pluginThemes.push(pluginTheme());
expect(await invoke("themes.set", { id: "space-pack/xenovessel", mode: "dark" })).toMatchObject(
@ -562,39 +802,44 @@ describe("theme RPC", () => {
expect(getUserPreferences(otherProfileId)).toEqual({});
});
it.each(["select", "mode-only"])(
"refuses a plugin palette replaced before commit during %s",
async (action) => {
const installed = pluginTheme();
pluginThemes.push(installed);
const original = {
"ui.theme": action === "select" ? "claw" : installed.id,
"ui.themeMode": "system",
"ui.accent": "#aabbcc",
it.each(
["select", "mode-only"].flatMap((action) =>
["replaced", "unavailable"].map((change) => ({ action, change })),
),
)("refuses a plugin palette $change before commit during $action", async ({ action, change }) => {
const installed = pluginTheme();
pluginThemes.push(installed);
const original = {
"ui.theme": action === "select" ? "claw" : installed.id,
"ui.themeMode": "system",
"ui.accent": "#aabbcc",
};
expect(setUserPreferences(requesterProfileId, original).ok).toBe(true);
let reachedCommit = false;
beforeWorkerCommit(() => {
reachedCommit = true;
if (change === "unavailable") {
pluginThemes.length = 0;
return;
}
pluginThemes[0] = {
...installed,
modes: ["light"],
definition: {
name: installed.name,
description: "Replacement light-only palette",
light: createThemePaletteFixture(),
},
};
expect(setUserPreferences(requesterProfileId, original).ok).toBe(true);
let reachedCommit = false;
beforeWorkerCommit(() => {
reachedCommit = true;
pluginThemes[0] = {
...installed,
modes: ["light"],
definition: {
name: installed.name,
description: "Replacement light-only palette",
light: createThemePaletteFixture(),
},
};
});
const params = action === "select" ? { id: installed.id, mode: "dark" } : { mode: "dark" };
expect(await invoke("themes.set", params)).toMatchObject({
ok: false,
error: { message: expect.stringContaining("theme plugin changed") },
});
expect(reachedCommit).toBe(true);
expect(getUserPreferences(requesterProfileId)).toEqual(original);
},
);
});
const params = action === "select" ? { id: installed.id, mode: "dark" } : { mode: "dark" };
expect(await invoke("themes.set", params)).toMatchObject({
ok: false,
error: { message: expect.stringContaining("theme plugin changed") },
});
expect(reachedCommit).toBe(true);
expect(getUserPreferences(requesterProfileId)).toEqual(original);
});
it("rolls back both import and selection when the live run retires at the commit boundary", async () => {
const identity = runtimeIdentity(requesterProfileId);

View file

@ -189,7 +189,7 @@ export function createApplicationTheme(
catalogRequested = true;
void loadCatalog();
}
return catalog?.snapshot ?? catalogLoadError;
return catalog?.snapshot(settings.theme) ?? catalogLoadError;
},
get settings() {
return settings;

View file

@ -2,7 +2,10 @@
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { ThemesListResult } from "../../../packages/gateway-protocol/src/schema/themes.ts";
import type {
ThemesGetResult,
ThemesListResult,
} from "../../../packages/gateway-protocol/src/schema/themes.ts";
import {
BUILTIN_THEMES,
type ThemeDescriptor,
@ -214,6 +217,296 @@ it("retries a failed selected palette only after an explicit catalog retry", asy
}
});
it("does not report a previous palette failure after a local theme switch", async () => {
const replacement: ThemeDescriptor = {
...descriptor,
id: "space-pack/afterglow",
name: "Afterglow",
};
const replacementDefinition = createThemeDefinitionFixture({
name: replacement.name,
description: replacement.description,
dark: createThemePaletteFixture({ background: "#332211" }),
});
const selected: ThemesGetResult = {
theme: expectDefined(BUILTIN_THEMES[0], "default built-in theme"),
current: { id: "claw", mode: "system", scope: "profile", overrides: {} },
};
const retired = createDeferred<ThemesGetResult>();
const { gateway, current } = createGatewayStoreTestStore();
const applicationTheme = createApplicationTheme(loadSettings(), gateway);
gateway.start();
let paletteReads = 0;
current().request.mockImplementation((method, params) => {
if (method === "themes.list") {
return Promise.resolve({
...selected,
themes: [...BUILTIN_THEMES, descriptor, replacement],
} satisfies ThemesListResult);
}
if (method === "themes.get") {
paletteReads += 1;
if (paletteReads === 1) {
expect(params).toEqual({ id: descriptor.id });
return retired.promise;
}
expect(params).toEqual({ id: replacement.id });
return Promise.resolve({
current: selected.current,
theme: replacement,
definition: replacementDefinition,
} satisfies ThemesGetResult);
}
return Promise.reject(new Error(`Unexpected request ${method}`));
});
current().opts.onHello?.({
...GATEWAY_STORE_TEST_HELLO,
auth: { role: "operator", scopes: ["operator.read"] },
snapshot: { presence: [{ instanceId: current().instanceId, user: { id: "read-only" } }] },
});
try {
await vi.waitFor(() =>
expect(current().request).toHaveBeenCalledWith("themes.get", { id: descriptor.id }),
);
patchSettings({ theme: replacement.id });
await vi.waitFor(() => expect(document.documentElement.dataset.themeId).toBe(replacement.id));
expect(applicationTheme.settings.theme).toBe(replacement.id);
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
"--bg: #332211;",
);
retired.reject(new Error("Previous palette is unavailable"));
await retired.promise.catch(() => undefined);
expect(document.documentElement.dataset.themeId).toBe(replacement.id);
expect(applicationTheme.catalog?.error).toBeNull();
expect(paletteReads).toBe(2);
} finally {
applicationTheme.dispose();
gateway.stop();
}
});
it("keeps a local palette selected during catalog refresh and reloads later versions", async () => {
patchSettings({ theme: "claw" });
const response: ThemesListResult = {
themes: [...BUILTIN_THEMES, descriptor],
theme: expectDefined(BUILTIN_THEMES[0], "default built-in theme"),
current: { id: "claw", mode: "system", scope: "profile", overrides: {} },
};
const refreshing = createDeferred<ThemesListResult>();
const { gateway, current } = createGatewayStoreTestStore();
const applicationTheme = createApplicationTheme(loadSettings(), gateway);
gateway.start();
let lists = 0;
let palette = definition;
let paletteResponse: Promise<ThemesGetResult> | undefined;
let paletteReads = 0;
current().request.mockImplementation((method) => {
if (method === "themes.list") {
lists += 1;
return lists === 2 ? refreshing.promise : Promise.resolve(response);
}
if (method === "themes.get") {
paletteReads += 1;
return paletteResponse ?? Promise.resolve({ ...catalog(palette), current: response.current });
}
if (method === "plugins.uiDescriptors") {
return Promise.resolve({ ok: true, generation: 1, descriptors: [], methods: [] });
}
return Promise.reject(new Error(`Unexpected request ${method}`));
});
current().opts.onHello?.({ ...GATEWAY_STORE_TEST_HELLO });
try {
await vi.waitFor(() => expect(applicationTheme.catalog?.themes).toContainEqual(descriptor));
current().opts.onEvent?.(createGatewayEvent("plugins.changed", { generation: 1 }));
await vi.waitFor(() => expect(lists).toBe(2));
patchSettings({ theme: descriptor.id });
await vi.waitFor(() => expect(document.documentElement.dataset.themeId).toBe(descriptor.id));
refreshing.resolve(response);
await refreshing.promise;
await vi.waitFor(() => expect(document.documentElement.dataset.themeId).toBe(descriptor.id));
expect(applicationTheme.settings.theme).toBe(descriptor.id);
palette = createThemeDefinitionFixture({
dark: createThemePaletteFixture({ background: "#442244" }),
});
const delayed = createDeferred<ThemesGetResult>();
paletteResponse = delayed.promise;
current().opts.onEvent?.(createGatewayEvent("plugins.changed", { generation: 2 }));
await vi.waitFor(() => expect(paletteReads).toBe(2));
expect(document.documentElement.dataset.themeId).toBe(descriptor.id);
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
"--bg: #111122;",
);
delayed.resolve({ ...catalog(palette), current: response.current });
await delayed.promise;
await vi.waitFor(() =>
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
"--bg: #442244;",
),
);
const failed = createDeferred<ThemesGetResult>();
paletteResponse = failed.promise;
current().opts.onEvent?.(createGatewayEvent("plugins.changed", { generation: 3 }));
await vi.waitFor(() => expect(paletteReads).toBe(3));
failed.reject(new Error("Palette refresh temporarily failed"));
await failed.promise.catch(() => undefined);
expect(document.documentElement.dataset.themeId).toBe(descriptor.id);
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
"--bg: #442244;",
);
expect(applicationTheme.catalog?.error).toBe("Palette refresh temporarily failed");
patchSettings({ textScale: 110 });
expect(paletteReads).toBe(3);
paletteResponse = undefined;
palette = createThemeDefinitionFixture({
dark: createThemePaletteFixture({ background: "#553355" }),
});
expectDefined(applicationTheme.retryCatalog, "theme catalog retry")();
await vi.waitFor(() =>
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
"--bg: #553355;",
),
);
expect(applicationTheme.catalog?.error).toBeNull();
} finally {
applicationTheme.dispose();
gateway.stop();
}
});
it.each(["success", "failure"] as const)(
"ignores a late palette %s after its plugin disappears during refresh",
async (outcome) => {
patchSettings({ theme: "claw" });
const response: ThemesListResult = {
themes: [...BUILTIN_THEMES, descriptor],
theme: expectDefined(BUILTIN_THEMES[0], "default built-in theme"),
current: { id: "claw", mode: "system", scope: "profile", overrides: {} },
};
const refreshing = createDeferred<ThemesListResult>();
const retired = createDeferred<ThemesGetResult>();
const { gateway, current } = createGatewayStoreTestStore();
const applicationTheme = createApplicationTheme(loadSettings(), gateway);
gateway.start();
let lists = 0;
current().request.mockImplementation((method) => {
if (method === "themes.list") {
return ++lists === 1 ? Promise.resolve(response) : refreshing.promise;
}
if (method === "themes.get") {
return retired.promise;
}
if (method === "plugins.uiDescriptors") {
return Promise.resolve({ ok: true, generation: 1, descriptors: [], methods: [] });
}
return Promise.reject(new Error(`Unexpected request ${method}`));
});
current().opts.onHello?.({ ...GATEWAY_STORE_TEST_HELLO });
try {
await vi.waitFor(() => expect(applicationTheme.catalog?.themes).toContainEqual(descriptor));
current().opts.onEvent?.(createGatewayEvent("plugins.changed", { generation: 1 }));
await vi.waitFor(() => expect(lists).toBe(2));
patchSettings({ theme: descriptor.id });
await vi.waitFor(() =>
expect(current().request).toHaveBeenCalledWith("themes.get", { id: descriptor.id }),
);
refreshing.resolve({ ...response, themes: [...BUILTIN_THEMES] });
await refreshing.promise;
expect(applicationTheme.catalog?.themes).toEqual(BUILTIN_THEMES);
if (outcome === "success") {
retired.resolve({ ...catalog(), current: response.current });
} else {
retired.reject(new Error("Removed palette is unavailable"));
}
await retired.promise.catch(() => undefined);
expect(applicationTheme.settings.theme).toBe(descriptor.id);
expect(document.documentElement.dataset.themeId).toBe("claw");
expect(document.getElementById("openclaw-custom-theme")).toBeNull();
expect(applicationTheme.catalog?.error).toBeNull();
} finally {
applicationTheme.dispose();
gateway.stop();
}
},
);
it.each(["profile", "client"] as const)(
"discards a late personal palette after the requesting %s changes",
async (boundary) => {
const personal: ThemeDescriptor = {
id: "user/personal",
name: "Personal",
description: "A profile-owned palette.",
source: "user",
modes: ["dark"],
};
patchSettings({ theme: personal.id });
const response: ThemesListResult = {
themes: [...BUILTIN_THEMES, personal],
theme: expectDefined(BUILTIN_THEMES[0], "default built-in theme"),
current: { id: "claw", mode: "system", scope: "profile", overrides: {} },
};
const retired = createDeferred<ThemesGetResult>();
const { gateway, current } = createGatewayStoreTestStore();
const applicationTheme = createApplicationTheme(loadSettings(), gateway);
gateway.start();
current().request.mockImplementation((method) =>
method === "themes.get" ? retired.promise : Promise.resolve(response),
);
current().opts.onHello?.({
...GATEWAY_STORE_TEST_HELLO,
snapshot: { presence: [{ instanceId: current().instanceId, user: { id: "first" } }] },
});
try {
await vi.waitFor(() =>
expect(current().request).toHaveBeenCalledWith("themes.get", { id: personal.id }),
);
if (boundary === "client") {
gateway.connect();
}
current().request.mockResolvedValue({
...response,
theme: personal,
definition: createThemeDefinitionFixture({
dark: createThemePaletteFixture({ background: "#443355" }),
}),
current: { ...response.current, id: personal.id },
} satisfies ThemesListResult);
if (boundary === "profile") {
current().opts.onEvent?.(
createGatewayEvent("presence", {
presence: [{ instanceId: current().instanceId, user: { id: "second" } }],
}),
);
} else {
current().opts.onHello?.({
...GATEWAY_STORE_TEST_HELLO,
snapshot: { presence: [{ instanceId: current().instanceId, user: { id: "first" } }] },
});
}
await vi.waitFor(() =>
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
"--bg: #443355;",
),
);
retired.resolve({ ...response, theme: personal, definition });
await retired.promise;
expect(document.getElementById("openclaw-custom-theme")?.textContent).toContain(
"--bg: #443355;",
);
expect(applicationTheme.catalog?.error).toBeNull();
} finally {
applicationTheme.dispose();
gateway.stop();
}
},
);
it.each(["Appearance discovery", "profile selection"] as const)(
"leaves built-in presentation quiet until %s needs the catalog",
async (trigger) => {

View file

@ -33,7 +33,8 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
let ownerConnected = gateway.snapshot.phase === "connected";
let generation = 0;
let disposed = false;
const definitions = new Map<string, CatalogTheme>();
const definitions = new Map<string, { generation: number; theme: CatalogTheme }>();
const definitionErrors = new Map<string, string>();
const requested = new Set<string>();
const remainsCurrent = (request: number) =>
@ -48,11 +49,15 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
const light = definition?.light ?? definition?.dark;
const dark = definition?.dark ?? definition?.light;
if (definition && light && dark) {
definitionErrors.delete(result.theme.id);
definitions.set(result.theme.id, {
mode: !definition.light ? "dark" : !definition.dark ? "light" : undefined,
palette: {
light: normalizeThemePalette("light", light, undefined),
dark: normalizeThemePalette("dark", dark, undefined),
generation,
theme: {
mode: !definition.light ? "dark" : !definition.dark ? "light" : undefined,
palette: {
light: normalizeThemePalette("light", light, undefined),
dark: normalizeThemePalette("dark", dark, undefined),
},
},
});
}
@ -67,12 +72,23 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
ownerProfile = gateway.snapshot.selfUser?.id;
const request = ++generation;
requested.clear();
definitionErrors.clear();
try {
const result = await client.request<ThemesListResult>("themes.list", {});
if (!remainsCurrent(request)) {
return;
}
definitions.clear();
const available = new Set<string>(result.themes.map((theme) => theme.id));
for (const id of definitions.keys()) {
if (!available.has(id)) {
definitions.delete(id);
}
}
for (const id of definitionErrors.keys()) {
if (!available.has(id)) {
definitionErrors.delete(id);
}
}
rememberDefinition(result);
snapshot = { themes: result.themes, error: null, unavailableId: result.current.requestedId };
onChange();
@ -89,7 +105,7 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
if (
id === "custom" ||
isBuiltinThemeId(id) ||
definitions.has(id) ||
definitions.get(id)?.generation === generation ||
requested.has(id) ||
!snapshot.themes.some((theme) => theme.id === id) ||
gateway.snapshot.phase !== "connected" ||
@ -99,20 +115,19 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
}
requested.add(id);
const request = generation;
const remainsAvailable = () =>
remainsCurrent(request) && snapshot.themes.some((theme) => theme.id === id);
void client.request<ThemesGetResult>("themes.get", { id }).then(
(result) => {
if (!remainsCurrent(request)) {
if (!remainsAvailable()) {
return;
}
rememberDefinition(result);
onChange();
},
(error: unknown) => {
if (remainsCurrent(request)) {
snapshot = {
...snapshot,
error: error instanceof Error ? error.message : String(error),
};
if (remainsAvailable()) {
definitionErrors.set(id, error instanceof Error ? error.message : String(error));
onChange();
}
},
@ -121,6 +136,7 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
const clear = () => {
definitions.clear();
definitionErrors.clear();
requested.clear();
snapshot = { themes: [], error: null };
onChange();
@ -177,12 +193,13 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
}
return {
get snapshot() {
return snapshot;
snapshot(theme: ThemeName): ThemeCatalogSnapshot {
const error = snapshot.error ?? definitionErrors.get(theme) ?? null;
return error === snapshot.error ? snapshot : { ...snapshot, error };
},
theme(id: ThemeName) {
ensureDefinition(id);
return definitions.get(id);
return definitions.get(id)?.theme;
},
refresh,
dispose() {
@ -191,6 +208,7 @@ export function createThemeCatalog(gateway: ApplicationGateway, onChange: () =>
stopGateway();
stopEvents();
definitions.clear();
definitionErrors.clear();
},
};
}

View file

@ -203,7 +203,11 @@ export function renderAppearanceSection(
: t("configView.appearance.importHint"),
},
];
const presentedTheme = themeOptions.find((option) => option.id === props.theme) ?? {
const selectedTheme = themeOptions.find((option) => option.id === props.theme);
const themeUnavailable =
props.themeCatalog?.unavailableId === props.theme ||
(props.theme.includes("/") && Boolean(props.themeCatalog?.themes.length) && !selectedTheme);
const presentedTheme = selectedTheme ?? {
id: UI_APPEARANCE_DEFAULTS.theme,
label: t("configView.themes.claw.label"),
};
@ -257,7 +261,7 @@ export function renderAppearanceSection(
${themeProvenance}
</p>
${
props.themeCatalog?.unavailableId === props.theme
themeUnavailable
? html`<p class="settings-section__desc" role="status">
${t("configView.appearance.themeUnavailable", { id: props.theme })}
</p>`

View file

@ -45,41 +45,44 @@ it("offers plugin and personal themes from the shared catalog with their descrip
);
});
it("shows Claw presentation while preserving an unavailable saved plugin selection", async () => {
const missingTheme = "space-pack/xenovessel";
const { container, props } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
theme: missingTheme,
themeOverridden: true,
themeProvenance: "profile",
themeCatalog: {
error: null,
themes: [...BUILTIN_THEMES],
unavailableId: missingTheme,
},
});
await updatePickers(container);
expect(container.textContent).toContain(
`${missingTheme} is unavailable. Using Claw until the theme becomes available again.`,
);
const claw = container.querySelector<HTMLButtonElement>('[data-theme-id="claw"]');
expect(claw?.getAttribute("aria-pressed")).toBe("true");
expect(container.querySelector('[data-accent-preset="default"]')?.classList).toContain(
"settings-accent-theme--claw",
);
for (const slot of ["ui", "chat"]) {
const picker = container
.querySelector(`#settings-font-${slot}`)
?.closest("openclaw-select-picker");
expect(picker?.querySelector('[role="option"][data-value="theme"]')?.textContent).toContain(
"Claw · Instrument Sans",
it.each(["profile", "device-local"] as const)(
"shows Claw presentation while preserving an unavailable %s plugin selection",
async (provenance) => {
const missingTheme = "space-pack/xenovessel";
const { container, props } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
theme: missingTheme,
themeOverridden: true,
themeProvenance: provenance,
themeCatalog: {
error: null,
themes: [...BUILTIN_THEMES],
...(provenance === "profile" ? { unavailableId: missingTheme } : {}),
},
});
await updatePickers(container);
expect(container.textContent).toContain(
`${missingTheme} is unavailable. Using Claw until the theme becomes available again.`,
);
}
expect(props.theme).toBe(missingTheme);
claw?.click();
expect(props.setTheme).toHaveBeenCalledWith("claw", { element: claw });
});
const claw = container.querySelector<HTMLButtonElement>('[data-theme-id="claw"]');
expect(claw?.getAttribute("aria-pressed")).toBe("true");
expect(container.querySelector('[data-accent-preset="default"]')?.classList).toContain(
"settings-accent-theme--claw",
);
for (const slot of ["ui", "chat"]) {
const picker = container
.querySelector(`#settings-font-${slot}`)
?.closest("openclaw-select-picker");
expect(picker?.querySelector('[role="option"][data-value="theme"]')?.textContent).toContain(
"Claw · Instrument Sans",
);
}
expect(props.theme).toBe(missingTheme);
claw?.click();
expect(props.setTheme).toHaveBeenCalledWith("claw", { element: claw });
},
);
it("offers an explicit retry when the theme catalog cannot load", () => {
const onRetryThemeCatalog = vi.fn();