ui: fix theme mode and locale regressions

This commit is contained in:
Val Alexander
2026-03-10 21:57:56 -05:00
parent f61b99352c
commit 0195c5e7d5
8 changed files with 142 additions and 32 deletions

View File

@@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts";
export const pt_BR: TranslationMap = {
common: {
version: "Versão",
health: "Saúde",
ok: "OK",
offline: "Offline",

View File

@@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts";
export const zh_CN: TranslationMap = {
common: {
version: "版本",
health: "健康状况",
ok: "正常",
offline: "离线",

View File

@@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts";
export const zh_TW: TranslationMap = {
common: {
version: "版本",
health: "健康狀況",
ok: "正常",
offline: "離線",

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { i18n, t } from "../lib/translate.ts";
import { pt_BR } from "../locales/pt-BR.ts";
import { zh_CN } from "../locales/zh-CN.ts";
import { zh_TW } from "../locales/zh-TW.ts";
describe("i18n", () => {
beforeEach(async () => {
@@ -44,13 +47,17 @@ describe("i18n", () => {
it("loads saved non-English locale on startup", async () => {
localStorage.setItem("openclaw.i18n.locale", "zh-CN");
vi.resetModules();
const fresh = await import("../lib/translate.ts");
for (let index = 0; index < 5 && fresh.i18n.getLocale() !== "zh-CN"; index += 1) {
await Promise.resolve();
}
const fresh = await import("../lib/translate.ts?startup-locale");
await vi.waitFor(() => {
expect(fresh.i18n.getLocale()).toBe("zh-CN");
});
expect(fresh.i18n.getLocale()).toBe("zh-CN");
expect(fresh.t("common.health")).toBe("健康状况");
});
it("keeps the version label available in shipped locales", () => {
expect(pt_BR.common.version).toBeTruthy();
expect(zh_CN.common.version).toBeTruthy();
expect(zh_TW.common.version).toBeTruthy();
});
});

View File

@@ -490,7 +490,7 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
export function renderThemeToggle(state: AppViewState) {
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
const index = Math.max(0, THEME_ORDER.indexOf(state.themeMode));
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element };
@@ -498,7 +498,7 @@ export function renderThemeToggle(state: AppViewState) {
context.pointerClientX = event.clientX;
context.pointerClientY = event.clientY;
}
state.setTheme(next, context);
state.setThemeMode(next, context);
};
return html`
@@ -506,27 +506,27 @@ export function renderThemeToggle(state: AppViewState) {
<div class="theme-toggle__track" role="group" aria-label="Theme">
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
class="theme-toggle__button ${state.themeMode === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-pressed=${state.themeMode === "system"}
aria-label="System theme"
title="System"
>
${renderMonitorIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
class="theme-toggle__button ${state.themeMode === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-pressed=${state.themeMode === "light"}
aria-label="Light theme"
title="Light"
>
${renderSunIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
class="theme-toggle__button ${state.themeMode === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-pressed=${state.themeMode === "dark"}
aria-label="Dark theme"
title="Dark"
>

View File

@@ -1,8 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setTabFromRoute } from "./app-settings.ts";
import {
applySettings,
attachThemeListener,
setTabFromRoute,
syncThemeWithSettings,
} from "./app-settings.ts";
import type { Tab } from "./navigation.ts";
import type { ThemeMode, ThemeName } from "./theme.ts";
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
themeMode: ThemeMode;
logsPollInterval: number | null;
debugPollInterval: number | null;
};
@@ -13,14 +20,17 @@ const createHost = (tab: Tab): SettingsHost => ({
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
},
theme: "system",
theme: "claw" as unknown as ThemeName & ThemeMode,
themeMode: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
@@ -67,4 +77,57 @@ describe("setTabFromRoute", () => {
setTabFromRoute(host, "chat");
expect(host.debugPollInterval).toBeNull();
});
it("re-resolves the active palette when only themeMode changes", () => {
const host = createHost("chat");
host.settings.theme = "knot";
host.settings.themeMode = "dark";
host.theme = "knot" as unknown as ThemeName & ThemeMode;
host.themeMode = "dark";
host.themeResolved = "openknot";
applySettings(host, {
...host.settings,
themeMode: "light",
});
expect(host.theme).toBe("knot");
expect(host.themeMode).toBe("light");
expect(host.themeResolved).toBe("openknot-light");
});
it("syncs both theme family and mode from persisted settings", () => {
const host = createHost("chat");
host.settings.theme = "dash";
host.settings.themeMode = "light";
syncThemeWithSettings(host);
expect(host.theme).toBe("dash");
expect(host.themeMode).toBe("light");
expect(host.themeResolved).toBe("dash-light");
});
it("applies named system themes on OS preference changes", () => {
const listeners: Array<(event: MediaQueryListEvent) => void> = [];
const matchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: (_name: string, handler: (event: MediaQueryListEvent) => void) => {
listeners.push(handler);
},
removeEventListener: vi.fn(),
});
vi.stubGlobal("matchMedia", matchMedia);
const host = createHost("chat");
host.theme = "knot" as unknown as ThemeName & ThemeMode;
host.themeMode = "system";
attachThemeListener(host);
listeners[0]?.({ matches: true } as MediaQueryListEvent);
expect(host.themeResolved).toBe("openknot");
listeners[0]?.({ matches: false } as MediaQueryListEvent);
expect(host.themeResolved).toBe("openknot-light");
});
});

View File

@@ -36,13 +36,20 @@ import {
} from "./navigation.ts";
import { saveSettings, type UiSettings } from "./storage.ts";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
import { colorSchemeForTheme, resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
import {
colorSchemeForTheme,
resolveTheme,
type ResolvedTheme,
type ThemeMode,
type ThemeName,
} from "./theme.ts";
import type { AgentsListResult } from "./types.ts";
type SettingsHost = {
settings: UiSettings;
password?: string;
theme: ThemeMode;
theme: ThemeName;
themeMode: ThemeMode;
themeResolved: ResolvedTheme;
applySessionKey: string;
sessionKey: string;
@@ -69,9 +76,10 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
};
host.settings = normalized;
saveSettings(normalized);
if (next.theme !== host.theme) {
if (next.theme !== host.theme || next.themeMode !== host.themeMode) {
host.theme = next.theme;
applyResolvedTheme(host, resolveTheme(next.theme));
host.themeMode = next.themeMode;
applyResolvedTheme(host, resolveTheme(next.theme, next.themeMode));
}
host.applySessionKey = host.settings.lastActiveSessionKey;
}
@@ -166,17 +174,35 @@ export function setTab(host: SettingsHost, next: Tab) {
applyTabSelection(host, next, { refreshPolicy: "always", syncUrl: true });
}
export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTransitionContext) {
export function setTheme(host: SettingsHost, next: ThemeName, context?: ThemeTransitionContext) {
const applyTheme = () => {
host.theme = next;
applySettings(host, { ...host.settings, theme: next });
applyResolvedTheme(host, resolveTheme(next));
applyResolvedTheme(host, resolveTheme(next, host.themeMode));
};
startThemeTransition({
nextTheme: next,
nextTheme: resolveTheme(next, host.themeMode),
applyTheme,
context,
currentTheme: host.theme,
currentTheme: host.themeResolved,
});
}
export function setThemeMode(
host: SettingsHost,
next: ThemeMode,
context?: ThemeTransitionContext,
) {
const applyTheme = () => {
host.themeMode = next;
applySettings(host, { ...host.settings, themeMode: next });
applyResolvedTheme(host, resolveTheme(host.theme, next));
};
startThemeTransition({
nextTheme: resolveTheme(host.theme, next),
applyTheme,
context,
currentTheme: host.themeResolved,
});
}
@@ -262,8 +288,9 @@ export function inferBasePath() {
}
export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme));
host.theme = host.settings.theme;
host.themeMode = host.settings.themeMode;
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
}
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
@@ -282,10 +309,10 @@ export function attachThemeListener(host: SettingsHost) {
}
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
host.themeMediaHandler = (event) => {
if (host.theme !== "system") {
if (host.themeMode !== "system") {
return;
}
applyResolvedTheme(host, event.matches ? "dark" : "light");
applyResolvedTheme(host, resolveTheme(host.theme, event.matches ? "dark" : "light"));
};
if (typeof host.themeMedia.addEventListener === "function") {
host.themeMedia.addEventListener("change", host.themeMediaHandler);

View File

@@ -42,6 +42,7 @@ import {
loadOverview as loadOverviewInternal,
setTab as setTabInternal,
setTheme as setThemeInternal,
setThemeMode as setThemeModeInternal,
onPopState as onPopStateInternal,
} from "./app-settings.ts";
import {
@@ -61,7 +62,7 @@ import type { SkillMessage } from "./controllers/skills.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { loadSettings, type UiSettings } from "./storage.ts";
import type { ResolvedTheme, ThemeMode } from "./theme.ts";
import type { ResolvedTheme, ThemeMode, ThemeName } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
@@ -123,7 +124,8 @@ export class OpenClawApp extends LitElement {
@state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode();
@state() connected = false;
@state() theme: ThemeMode = this.settings.theme ?? "system";
@state() theme: ThemeName = this.settings.theme;
@state() themeMode: ThemeMode = this.settings.themeMode;
@state() themeResolved: ResolvedTheme = "dark";
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@@ -471,10 +473,18 @@ export class OpenClawApp extends LitElement {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
}
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
}
setThemeMode(next: ThemeMode, context?: Parameters<typeof setThemeModeInternal>[2]) {
setThemeModeInternal(
this as unknown as Parameters<typeof setThemeModeInternal>[0],
next,
context,
);
}
async loadOverview() {
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
}