mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
ui: fix theme mode and locale regressions
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const zh_CN: TranslationMap = {
|
||||
common: {
|
||||
version: "版本",
|
||||
health: "健康状况",
|
||||
ok: "正常",
|
||||
offline: "离线",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const zh_TW: TranslationMap = {
|
||||
common: {
|
||||
version: "版本",
|
||||
health: "健康狀況",
|
||||
ok: "正常",
|
||||
offline: "離線",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user