mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
improve(ui): apply theme defaults when switching themes (#152254)
Selecting a different Control UI theme applies its default interface/chat fonts and complete light/dark palette through the existing preference writer. Same-theme selections, passive updates and subsequent customization remain intact. Preserve the documented downgrade requirement: remove or replace a configured ui.prefs.accent="theme" before downgrading to a hex-only Gateway. The maintainer accepted this tradeoff. Verified with focused unit and browser coverage, independent review, and green exact-head CI. Co-authored-by: vyctorbrzezowski <51521767+vyctorbrzezowski@users.noreply.github.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
parent
2adc87245a
commit
db02d741e9
33 changed files with 663 additions and 209 deletions
|
|
@ -3535,7 +3535,7 @@ ui/src/app/native-route-memory.ts 1
|
|||
ui/src/app/native-web-chrome.ts 2
|
||||
ui/src/app/overlays.ts 1
|
||||
ui/src/app/server-prefs-state.ts 10
|
||||
ui/src/app/server-prefs.ts 10
|
||||
ui/src/app/server-prefs.ts 7
|
||||
ui/src/app/settings.ts 9
|
||||
ui/src/app/stale-chunk-reload.ts 1
|
||||
ui/src/app/startup-settings.ts 1
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
f31c9bf947222e28f65a9c266749741adb52f2f91a4a801b706352a5049956f2 config-baseline.json
|
||||
0ab65e81949cae8ebc073ce25c6f6aa9078db0ea1a74b63db7c09f29ba34fb99 config-baseline.core.json
|
||||
ef8a7e9b0efe163bc883e757ddf69901c1c18d55bbb3d1dda2d7652b17598c4a config-baseline.json
|
||||
b85771a5edee67eaeddfee00d4a0b7812908e01b87497940b31b9b8f44cad8a1 config-baseline.core.json
|
||||
51c84b118b136dfff84993e3cee0ff76ee06b614842b125d0fde110635fc10b8 config-baseline.channel.json
|
||||
7b4c6b0794f1401e1db725d2adabe2059be85d2f0ced82484d8b8b77287bc834 config-baseline.plugin.json
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ Personal connections share the Gateway's existing trusted-host boundary. They pr
|
|||
|
||||
When a Control UI connection is bound to an authenticated Gateway profile, OpenClaw stores its theme, theme mode, and accent color per profile. They live in the existing `user_preferences` table in the shared state database. Those choices follow that person across devices without changing appearance for other people on the same Gateway.
|
||||
|
||||
Profile theme and theme mode preferences override their gateway-wide `ui.prefs` settings and otherwise fall back to the active theme's defaults. Plugin themes use namespaced IDs such as `space-pack/xenovessel`. Personal theme definitions created through the agent are stored in the same profile preference store and follow the profile across browsers. The `theme` tool and Appearance share one catalog and selection owner. Plugin hot reload updates that catalog and connected browsers without a Gateway restart. An unavailable plugin theme temporarily renders as Claw while its saved selection is retained. Legacy tweakcn imports are the exception: their palettes stay in the browser that imported them, and are never uploaded automatically. Selecting that local import never follows the profile. Accent precedence is the profile's `ui.accent` preference, gateway-wide `ui.prefs.accent`, `ui.seamColor`, and finally the active theme's default accent. Restoring a default clears only the profile preference. Owner-profile preferences follow the owner across devices. Connections without a profile keep gateway-wide appearance behavior. Language, chat preferences, and sidebar entries continue using gateway configuration.
|
||||
Profile theme and theme mode preferences override their gateway-wide `ui.prefs` settings and otherwise fall back to the active theme's defaults. Plugin themes use namespaced IDs such as `space-pack/xenovessel`. Personal theme definitions created through the agent are stored in the same profile preference store and follow the profile across browsers. The `theme` tool and Appearance share one catalog and selection owner. Plugin hot reload updates that catalog and connected browsers without a Gateway restart. An unavailable plugin theme temporarily renders as Claw while its saved selection is retained. Legacy tweakcn imports are the exception: their palettes stay in the browser that imported them, and are never uploaded automatically. Selecting that local import never follows the profile. Accent precedence is the profile's `ui.accent` preference, gateway-wide `ui.prefs.accent`, `ui.seamColor`, and finally the active theme's default accent. Selecting a different theme in Appearance clears the profile font overrides and stores `ui.accent: "theme"`, explicitly selecting the theme palette without inheriting gateway accent colors. Restoring a default clears only the profile preference. Owner-profile preferences follow the owner across devices. Connections without a profile keep gateway-wide appearance behavior. Language, chat preferences, and sidebar entries continue using gateway configuration.
|
||||
|
||||
## Write directives, not observations
|
||||
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@ Agent display names, emoji, and avatars belong to each agent's `identity` block
|
|||
- `seamColor`: operator accent color for native app UI chrome (Talk Mode bubble
|
||||
tint, etc.). The Control UI user accent (`ui.prefs.accent`) takes precedence in
|
||||
`talk.config` payloads and the macOS app's config snapshot. If neither is set,
|
||||
the theme default applies.
|
||||
the theme default applies. `prefs.accent` also accepts `"theme"` to explicitly
|
||||
select the Control UI theme palette without inheriting `seamColor`; `talk.config`
|
||||
omits its hex-only accent in that case.
|
||||
- `prefs`: cross-device operator preferences. This is the canonical home so agents can
|
||||
change them through the approval gate and every Control UI client stays in
|
||||
sync; browsers mirror the values into local storage for instant boot. An
|
||||
|
|
|
|||
|
|
@ -137,11 +137,15 @@ The Appearance panel has the built-in Claw, Knot, Dash, Absolutely, Tide, Beacon
|
|||
|
||||
Themes imported from tweakcn are stored only in the current browser profile; they are not written to gateway config and do not sync across devices. Replacing the imported theme updates the one local slot; clearing it switches back to Claw if the imported theme was active.
|
||||
|
||||
Selecting a **different theme** in Appearance applies its complete default look, clearing the interface and chat font overrides and selecting its own accent palette. You can customize the fonts and accent afterward. Selecting the same theme, reloading, reconnecting, receiving synced preferences, or changing light/dark mode does not reset those customizations. Language, text size, chat display, and other unrelated preferences are unchanged.
|
||||
|
||||
The mounted UI keeps a live display-preference snapshot for its connected Gateway. Local changes and same-Gateway browser-tab edits update open composers without a reload. Selecting a different Gateway in another tab does not retarget the current tab. Credentials remain owned by the connection, separate from this display snapshot.
|
||||
|
||||
Choose an **Accent color** preset or custom color in Appearance to override the active theme's accent. For an authenticated Gateway profile, the accent precedence is the profile's `ui.accent` preference, the gateway-wide `ui.prefs.accent` setting, the operator-configured `ui.seamColor`, and finally the active theme's default. **Restore default** clears only that profile's preference, leaving the gateway-wide settings unchanged. Connections without an authenticated profile keep the existing gateway-wide preference behavior.
|
||||
Choose an **Accent color** preset or custom color in Appearance to override the active theme's accent. For an authenticated Gateway profile, the accent precedence is the profile's `ui.accent` preference, the gateway-wide `ui.prefs.accent` setting, the operator-configured `ui.seamColor`, and finally the active theme's default. A theme selection stores the explicit `"theme"` accent preference, which uses the selected theme's complete palette in both light and dark modes instead of inheriting gateway accent or seam colors. **Restore default** clears only that profile's preference, leaving the gateway-wide settings unchanged. Connections without an authenticated profile keep the existing gateway-wide preference behavior.
|
||||
|
||||
The **Typography** block lets you choose an **Interface** face and a separate **Chat prose** face. **Theme default** for Interface and **Match interface** for Chat prose restore the theme’s typography; Dash and Absolutely keep their own serif chat defaults. **System** uses the system sans-serif stack without loading a webfont. Code keeps its monospace stack. Opening either picker loads the self-hosted specimens on demand; startup loads only the active faces. Font overrides follow an authenticated Gateway profile, with a browser-local mirror for instant boot. Without a profile, they stay in that browser and are never written to `openclaw.json`.
|
||||
Existing unset and hex accents keep their meaning; upgrades do not migrate or reset saved preferences. Older Control UI readers that do not recognize the profile value `"theme"` fall back to inherited colors. Before downgrading to a Gateway whose `ui.prefs.accent` accepts only hex colors, remove a configured `"theme"` value or replace it with `#RRGGBB`. This adds no database schema version or preference key.
|
||||
|
||||
The **Typography** block lets you choose an **Interface** face and a separate **Chat prose** face. **Theme default** for both Interface and Chat prose restore the theme’s typography; Dash and Absolutely keep their own serif chat defaults. **System** uses the system sans-serif stack without loading a webfont. Code keeps its monospace stack. Opening either picker loads the self-hosted specimens on demand; startup loads only the active faces. Font overrides follow an authenticated Gateway profile, with a browser-local mirror for instant boot. Without a profile, they stay in that browser and are never written to `openclaw.json`.
|
||||
|
||||
Appearance also has a Text size setting. It applies to chat text, composer text, tool cards, and chat sidebars, and keeps text inputs at least 16px so mobile Safari does not auto-zoom on focus.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ export function normalizeUiAppearancePreference(
|
|||
return undefined;
|
||||
}
|
||||
if (key === UI_APPEARANCE_PREFERENCE_KEYS.accent) {
|
||||
return /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : undefined;
|
||||
// Explicit theme ownership is distinct from an absent (inherited) accent.
|
||||
return value === "theme" || /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : undefined;
|
||||
}
|
||||
if (
|
||||
key === UI_APPEARANCE_PREFERENCE_KEYS.fontUi ||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ describe("user preference protocol schemas", () => {
|
|||
expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.accent, "#A1b2C3")).toBe(
|
||||
"#a1b2c3",
|
||||
);
|
||||
expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.accent, "theme")).toBe(
|
||||
"theme",
|
||||
);
|
||||
expect(normalizeUiAppearancePreference(UI_APPEARANCE_PREFERENCE_KEYS.fontUi, "geist")).toBe(
|
||||
"geist",
|
||||
);
|
||||
|
|
|
|||
8
src/config/config-accent.test-support.ts
Normal file
8
src/config/config-accent.test-support.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const configAccentCases = [
|
||||
["theme default", "theme", true],
|
||||
["lowercase hex", "#ff5c5c", true],
|
||||
["uppercase hex", "#AbCdEf", true],
|
||||
["missing hash", "ff5c5c", false],
|
||||
["invalid hex", "#gggggg", false],
|
||||
["invalid length", "#ff5c5c00", false],
|
||||
] as const;
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
// Covers miscellaneous config schema defaults and validation cases.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configAccentCases } from "./config-accent.test-support.js";
|
||||
import {
|
||||
getConfigValueAtPath,
|
||||
parseConfigPath,
|
||||
|
|
@ -523,13 +524,7 @@ describe("ui.seamColor", () => {
|
|||
});
|
||||
|
||||
describe("ui.prefs.accent", () => {
|
||||
it.each([
|
||||
["lowercase hex", "#ff5c5c", true],
|
||||
["uppercase hex", "#AbCdEf", true],
|
||||
["missing hash", "ff5c5c", false],
|
||||
["invalid hex", "#gggggg", false],
|
||||
["invalid length", "#ff5c5c00", false],
|
||||
])("validates %s", (_label, accent, valid) => {
|
||||
it.each(configAccentCases)("validates %s", (_label, accent, valid) => {
|
||||
expect(validateConfigObject({ ui: { prefs: { accent } } }).ok).toBe(valid);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export const AGENT_FIELD_HELP: Record<string, string> = {
|
|||
"ui.seamColor":
|
||||
"Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.",
|
||||
"ui.prefs.accent":
|
||||
"User-selected Control UI accent color in #RRGGBB format. Overrides ui.seamColor; clear it to restore the configured seam color or theme default.",
|
||||
"Control UI accent: #RRGGBB for a custom color, or 'theme' for the selected theme’s palette. Overrides ui.seamColor; clear it to inherit the configured seam color or theme default.",
|
||||
tui: "Terminal UI display settings. Use this section for terminal-only presentation preferences without changing Gateway or other UI behavior.",
|
||||
"tui.footer":
|
||||
"Terminal UI footer display settings. Keep optional context compact so session, model, goal, and token information stay readable.",
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export type OpenClawConfig = {
|
|||
| "custom";
|
||||
/** Light/dark preference. */
|
||||
themeMode?: "light" | "dark" | "system";
|
||||
/** User-selected Control UI accent color (#RRGGBB). */
|
||||
/** Control UI accent: #RRGGBB, or "theme" to bypass inherited accent colors. */
|
||||
accent?: string;
|
||||
/** BCP 47 UI locale, e.g. "en" or "pt-BR". */
|
||||
locale?: string;
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ export const OpenClawSchemaShape = {
|
|||
themeMode: z
|
||||
.union([z.literal("light"), z.literal("dark"), z.literal("system")])
|
||||
.optional(),
|
||||
accent: HexColorSchema.startsWith("#").optional(),
|
||||
accent: z.union([z.literal("theme"), HexColorSchema.startsWith("#")]).optional(),
|
||||
locale: z.string().max(20).optional(),
|
||||
chatShowThinking: z.boolean().optional(),
|
||||
chatShowToolCalls: z.boolean().optional(),
|
||||
|
|
|
|||
24
src/gateway/talk/handlers/config-accent.test-support.ts
Normal file
24
src/gateway/talk/handlers/config-accent.test-support.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export const talkConfigAccentCases = [
|
||||
{
|
||||
name: "prefers the authenticated profile accent over gateway appearance defaults",
|
||||
profileId: "profile-1",
|
||||
profileAccent: "#A1B2C3",
|
||||
expectedAccent: "#a1b2c3",
|
||||
},
|
||||
{
|
||||
name: "omits a hex-only Talk accent when the profile selects theme defaults",
|
||||
profileId: "profile-1",
|
||||
profileAccent: "theme",
|
||||
expectedAccent: undefined,
|
||||
},
|
||||
{
|
||||
name: "ignores malformed authenticated profile accents",
|
||||
profileId: "profile-1",
|
||||
profileAccent: "not-a-color",
|
||||
expectedAccent: "#52c99a",
|
||||
},
|
||||
{
|
||||
name: "keeps profile-less callers on their existing gateway accent path",
|
||||
expectedAccent: "#52c99a",
|
||||
},
|
||||
];
|
||||
|
|
@ -38,6 +38,7 @@ import { resolveSessionMutationAuthorization } from "../../session-sharing.js";
|
|||
import { prepareTalkAgentConsultTranscript } from "../agent-consult-transcript.js";
|
||||
import { buildTalkRealtimeConfig } from "../session-config.js";
|
||||
import { forgetLegacyVoiceBinding } from "./client-legacy-voice-bindings.js";
|
||||
import { talkConfigAccentCases } from "./config-accent.test-support.js";
|
||||
import { talkHandlers } from "./index.js";
|
||||
import {
|
||||
expectRecordFields,
|
||||
|
|
@ -1252,24 +1253,7 @@ describe("talk.config handler", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "prefers the authenticated profile accent over gateway appearance defaults",
|
||||
profileId: "profile-1",
|
||||
profileAccent: "#A1B2C3",
|
||||
expectedAccent: "#a1b2c3",
|
||||
},
|
||||
{
|
||||
name: "ignores malformed authenticated profile accents",
|
||||
profileId: "profile-1",
|
||||
profileAccent: "not-a-color",
|
||||
expectedAccent: "#52c99a",
|
||||
},
|
||||
{
|
||||
name: "keeps profile-less callers on their existing gateway accent path",
|
||||
expectedAccent: "#52c99a",
|
||||
},
|
||||
])("$name", async ({ profileId, profileAccent, expectedAccent }) => {
|
||||
it.each(talkConfigAccentCases)("$name", async ({ profileId, profileAccent, expectedAccent }) => {
|
||||
markTalkOwnerCold("tts");
|
||||
const runtimeConfig = createTalkConfig("healthy-talk-key");
|
||||
mocks.getSpeechProvider.mockReturnValue({ id: "acme" });
|
||||
|
|
@ -1292,7 +1276,9 @@ describe("talk.config handler", () => {
|
|||
});
|
||||
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
expect(respond.mock.calls[0]?.[1]?.config?.ui).toEqual({ seamColor: expectedAccent });
|
||||
expect(respond.mock.calls[0]?.[1]?.config?.ui).toEqual(
|
||||
expectedAccent ? { seamColor: expectedAccent } : undefined,
|
||||
);
|
||||
if (profileId) {
|
||||
expect(mocks.getCanonicalUserPreferences).toHaveBeenCalledWith(profileId, ["ui.accent"]);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -932,7 +932,7 @@ export const talkHandlers: GatewayRequestHandlers = {
|
|||
// Profile accent overrides gateway prefs, then the gateway seam color and theme default.
|
||||
const seamColor =
|
||||
profileAccent ?? snapshot.config.ui?.prefs?.accent ?? snapshot.config.ui?.seamColor;
|
||||
if (typeof seamColor === "string") {
|
||||
if (typeof seamColor === "string" && seamColor !== "theme") {
|
||||
configPayload.ui = { seamColor };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import type { PropertyValues } from "lit";
|
||||
import { property, query, state } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient, GatewayEventFrame } from "../api/gateway.ts";
|
||||
import "../components/app-topbar.ts";
|
||||
import "../components/assistant-panel.ts";
|
||||
import "../components/modal-dialog.ts";
|
||||
import {
|
||||
formatDocumentTitle,
|
||||
isSettingsNavigationRoute,
|
||||
titleForRoute,
|
||||
} from "../app-navigation.ts";
|
||||
import "../components/resizable-divider.ts";
|
||||
import "../components/app-topbar.ts";
|
||||
import "../components/assistant-panel.ts";
|
||||
import "../components/modal-dialog.ts";
|
||||
import { isSessionRouteId } from "../app-route-paths.ts";
|
||||
import "../components/resizable-divider.ts";
|
||||
import { APP_ROUTE_IDS, type RouteId } from "../app-routes.ts";
|
||||
import type {
|
||||
CommandPaletteElement,
|
||||
|
|
@ -71,11 +71,8 @@ import {
|
|||
import { postNativeNavState, type NativeNavState } from "./native-nav-state.ts";
|
||||
import { readNativeHistoryState, type NativeHistoryState } from "./native-web-chrome.ts";
|
||||
import { resolveOnboardingMode } from "./onboarding-mode.ts";
|
||||
import {
|
||||
changedServerUiPrefs,
|
||||
isApplyingServerUiPrefs,
|
||||
pushServerUiPrefs,
|
||||
} from "./server-prefs.ts";
|
||||
import { changedServerUiPrefs } from "./server-prefs-intent.ts";
|
||||
import { isApplyingServerUiPrefs, pushServerUiPrefs } from "./server-prefs.ts";
|
||||
import { setSettingsChangeListener } from "./settings.ts";
|
||||
import {
|
||||
isStaleChunkImportError,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ describe("Control UI accent presentation", () => {
|
|||
expect(style.getPropertyValue("--accent-foreground")).toBe("#000000");
|
||||
expect(style.getPropertyValue("--primary-foreground")).toBe("#000000");
|
||||
|
||||
applyControlUiAccent("theme");
|
||||
expect(style.getPropertyValue("--accent")).toBe("");
|
||||
expect(style.getPropertyValue("--primary-hover")).toBe("");
|
||||
applyControlUiPresentation({ environment: null, seamColor: "#abcdef" });
|
||||
expect(style.getPropertyValue("--accent")).toBe("");
|
||||
applyControlUiPresentation({ environment: null, seamColor: "#123456" });
|
||||
applyControlUiAccent();
|
||||
expect(style.getPropertyValue("--accent")).toBe("#123456");
|
||||
expect(style.getPropertyValue("--primary-foreground")).toBe("#ffffff");
|
||||
|
|
|
|||
|
|
@ -46,7 +46,10 @@ export function syncControlUiSystemChrome(): void {
|
|||
export function applyControlUiAccent(userAccent?: string): void {
|
||||
userAccentOverride = userAccent;
|
||||
const root = document.documentElement;
|
||||
const hex = (userAccentOverride ?? operatorSeamColor)?.trim().replace(/^#/, "");
|
||||
// An explicit theme selection must not expose the lower-precedence seam color.
|
||||
const accent =
|
||||
userAccentOverride === "theme" ? undefined : (userAccentOverride ?? operatorSeamColor);
|
||||
const hex = accent?.trim().replace(/^#/, "");
|
||||
const color = hex && /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null;
|
||||
if (!color) {
|
||||
for (const property of ACCENT_CSS_VARIABLES) {
|
||||
|
|
|
|||
83
ui/src/app/server-prefs-intent.ts
Normal file
83
ui/src/app/server-prefs-intent.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
prefValuesEqual,
|
||||
SYNCED_PREF_KEYS,
|
||||
SYNCED_PREFS,
|
||||
type ServerUiPrefs,
|
||||
type SyncedPrefKey,
|
||||
} from "./server-prefs-state.ts";
|
||||
import { loadSettings, patchSettings, type UiSettings } from "./settings.ts";
|
||||
import type { ThemeName } from "./theme.ts";
|
||||
|
||||
const requestedServerUiPrefResets = new Set<SyncedPrefKey>();
|
||||
const requestedDeviceLocalPrefResets = new Set<SyncedPrefKey>();
|
||||
const requestedUiPrefWrites = new Set<SyncedPrefKey>();
|
||||
|
||||
export function requestServerUiPrefReset(
|
||||
key: SyncedPrefKey,
|
||||
scope: "server" | "device-local",
|
||||
): void {
|
||||
(scope === "device-local" ? requestedDeviceLocalPrefResets : requestedServerUiPrefResets).add(
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
export function resetServerUiPrefIntent(): void {
|
||||
requestedServerUiPrefResets.clear();
|
||||
requestedDeviceLocalPrefResets.clear();
|
||||
requestedUiPrefWrites.clear();
|
||||
}
|
||||
|
||||
/** Synced-key delta between two local settings snapshots, for the push path. */
|
||||
export function changedServerUiPrefs(previous: UiSettings, next: UiSettings): ServerUiPrefs | null {
|
||||
const prefs: ServerUiPrefs = {};
|
||||
for (const key of SYNCED_PREF_KEYS) {
|
||||
const explicitWrite = requestedUiPrefWrites.delete(key);
|
||||
const serverReset = requestedServerUiPrefResets.delete(key);
|
||||
if (requestedDeviceLocalPrefResets.delete(key)) {
|
||||
continue;
|
||||
}
|
||||
if (serverReset) {
|
||||
prefs[key] = null;
|
||||
continue;
|
||||
}
|
||||
const specification = SYNCED_PREFS[key];
|
||||
const previousValue = specification.local(previous);
|
||||
const nextValue = specification.local(next);
|
||||
if (!explicitWrite && prefValuesEqual(previousValue, nextValue)) {
|
||||
continue;
|
||||
}
|
||||
if (nextValue === undefined) {
|
||||
// JSON merge patch removes keys via explicit null.
|
||||
if (specification.clearable) {
|
||||
prefs[key] = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// SAFETY: SYNCED_PREFS[key].local returns the value type owned by this exact key.
|
||||
(prefs as Record<string, unknown>)[key] = nextValue;
|
||||
}
|
||||
return Object.keys(prefs).length > 0 ? prefs : null;
|
||||
}
|
||||
/** Explicit user selection only; incoming snapshots and mode changes never reset design choices. */
|
||||
export function selectThemeSettings(
|
||||
theme: ThemeName,
|
||||
patch: Pick<Partial<UiSettings>, "customTheme"> = {},
|
||||
): UiSettings {
|
||||
if (theme === loadSettings().theme) {
|
||||
return patchSettings({ ...patch, theme });
|
||||
}
|
||||
// Clear even unresolved profile values: a missing boot mirror is not evidence
|
||||
// that the server has no font override. Send these with the theme in one batch.
|
||||
// Carry the whole selection intent even if another tab already mirrors this
|
||||
// marker, so a read-only selection can cancel every older queued design edit.
|
||||
requestedUiPrefWrites.add("accent");
|
||||
requestedServerUiPrefResets.add("fontUi");
|
||||
requestedServerUiPrefResets.add("fontChat");
|
||||
return patchSettings({
|
||||
...patch,
|
||||
theme,
|
||||
fontUi: undefined,
|
||||
fontChat: undefined,
|
||||
accent: "theme",
|
||||
});
|
||||
}
|
||||
|
|
@ -6,8 +6,10 @@ import {
|
|||
UI_APPEARANCE_PREFERENCE_KEYS,
|
||||
} from "../../../packages/gateway-protocol/src/schema/ui-appearance-preferences.ts";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import { createImportedCustomThemeFixture } from "../test-helpers/custom-theme.ts";
|
||||
import { createStorageMock } from "../test-helpers/storage.ts";
|
||||
import { waitForFast } from "../test-helpers/wait-for.ts";
|
||||
import { changedServerUiPrefs, selectThemeSettings } from "./server-prefs-intent.ts";
|
||||
import {
|
||||
extractServerUiPrefs,
|
||||
resolveServerUiPrefStateFromSnapshot,
|
||||
|
|
@ -15,7 +17,6 @@ import {
|
|||
import { configWithPrefs, createServerPrefsWriter } from "./server-prefs.test-support.ts";
|
||||
import {
|
||||
applyServerUiPrefs,
|
||||
changedServerUiPrefs,
|
||||
flushServerUiPrefs,
|
||||
pushServerUiPrefs,
|
||||
refreshProfileAppearancePrefs,
|
||||
|
|
@ -41,6 +42,113 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("profile-bound appearance preferences", () => {
|
||||
it("persists a selected theme's defaults above gateway accents and keeps later customizations", async () => {
|
||||
const config = configWithPrefs({ theme: "claw", accent: "#123456" });
|
||||
const entries: Record<string, unknown> = {
|
||||
"ui.theme": "dash",
|
||||
"ui.fontUi": "geist",
|
||||
"ui.fontChat": "geist",
|
||||
"ui.accent": "#abcdef",
|
||||
};
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "users.prefs.set" || method === "themes.set") {
|
||||
const theme = params as { id: string; appearance: Record<string, unknown> };
|
||||
const patch =
|
||||
method === "themes.set"
|
||||
? {
|
||||
"ui.theme": theme.id,
|
||||
...Object.fromEntries(
|
||||
Object.entries(theme.appearance).map(([key, value]) => [`ui.${key}`, value]),
|
||||
),
|
||||
}
|
||||
: (params as { entries: Record<string, unknown> }).entries;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete entries[key];
|
||||
} else {
|
||||
entries[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status: "ok" as const, entries: { ...entries } };
|
||||
});
|
||||
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
|
||||
const options = {
|
||||
client: writer.state.client!,
|
||||
profileId,
|
||||
scope,
|
||||
configObject: config,
|
||||
onApplied: vi.fn(),
|
||||
};
|
||||
await refreshProfileAppearancePrefs(options);
|
||||
patchSettings({ themeMode: "system", locale: "en", textScale: 125, chatShowThinking: false });
|
||||
const before = loadSettings();
|
||||
const selected = selectThemeSettings("absolutely");
|
||||
expect(selected).toMatchObject({
|
||||
theme: "absolutely",
|
||||
accent: "theme",
|
||||
themeMode: "system",
|
||||
locale: "en",
|
||||
textScale: 125,
|
||||
chatShowThinking: false,
|
||||
});
|
||||
expect(selected.fontUi).toBeUndefined();
|
||||
expect(selected.fontChat).toBeUndefined();
|
||||
const patch = changedServerUiPrefs(before, selected)!;
|
||||
expect(patch).toEqual({ theme: "absolutely", accent: "theme", fontUi: null, fontChat: null });
|
||||
const committed = vi.fn();
|
||||
pushServerUiPrefs(writer, patch, { profileId, canWrite: true, afterCommit: committed });
|
||||
await waitForFast(() => expect(committed).toHaveBeenCalled());
|
||||
expect(entries).toEqual({ "ui.theme": "absolutely", "ui.accent": "theme" });
|
||||
await refreshProfileAppearancePrefs(options);
|
||||
expect(loadSettings()).toMatchObject({ theme: "absolutely", accent: "theme" });
|
||||
expect(loadSettings().fontUi).toBeUndefined();
|
||||
expect(loadSettings().fontChat).toBeUndefined();
|
||||
expect(request.mock.calls.every(([method]) => method !== "config.patch")).toBe(true);
|
||||
|
||||
const customized = patchSettings({ fontUi: "geist", fontChat: "lora", accent: "#654321" });
|
||||
expect(selectThemeSettings("absolutely")).toEqual(customized);
|
||||
expect(changedServerUiPrefs(customized, loadSettings())).toBeNull();
|
||||
patchSettings({ themeMode: "light" });
|
||||
expect(loadSettings()).toMatchObject({ fontUi: "geist", fontChat: "lora", accent: "#654321" });
|
||||
});
|
||||
|
||||
it("preserves imported definitions and only resets design overrides when activation changes", () => {
|
||||
const customTheme = createImportedCustomThemeFixture();
|
||||
patchSettings({
|
||||
theme: "dash",
|
||||
fontUi: "geist",
|
||||
fontChat: "lora",
|
||||
accent: "#123456",
|
||||
customTheme,
|
||||
});
|
||||
const selected = selectThemeSettings("custom");
|
||||
expect(selected.customTheme).toEqual(customTheme);
|
||||
expect(selected).toMatchObject({ theme: "custom", accent: "theme" });
|
||||
expect(selected.fontUi).toBeUndefined();
|
||||
const customized = patchSettings({ fontUi: "geist", accent: "#123456" });
|
||||
expect(selectThemeSettings("custom", { customTheme })).toEqual(customized);
|
||||
const cleared = selectThemeSettings("claw", { customTheme: undefined });
|
||||
expect(cleared.customTheme).toBeUndefined();
|
||||
expect(cleared.fontUi).toBeUndefined();
|
||||
expect(cleared.accent).toBe("theme");
|
||||
patchSettings({ fontUi: "geist", accent: "#123456", customTheme });
|
||||
expect(selectThemeSettings("claw", { customTheme: undefined })).toMatchObject({
|
||||
fontUi: "geist",
|
||||
accent: "#123456",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears profile font keys even when the boot mirror has not loaded them", () => {
|
||||
const before = patchSettings({ theme: "dash", fontUi: undefined, fontChat: undefined });
|
||||
expect(changedServerUiPrefs(before, selectThemeSettings("absolutely"))).toEqual({
|
||||
theme: "absolutely",
|
||||
accent: "theme",
|
||||
fontUi: null,
|
||||
fontChat: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("stores every Control UI theme name the profile wire contract knows", () => {
|
||||
// Record<ThemeName, boolean> turns a theme added to the UI but missing from
|
||||
// this table into a compile error, and the loop turns a wire-contract
|
||||
|
|
@ -303,9 +411,11 @@ describe("profile-bound appearance preferences", () => {
|
|||
}
|
||||
expect(method).toBe(key === "theme" ? "themes.set" : "users.prefs.set");
|
||||
expect(params).toEqual(
|
||||
key === "theme" ? { id: null } : { entries: { [preferenceKey]: null } },
|
||||
key === "theme"
|
||||
? { id: null, appearance: { accent: "theme", fontUi: null, fontChat: null } }
|
||||
: { entries: { [preferenceKey]: null } },
|
||||
);
|
||||
entries = {};
|
||||
entries = key === "theme" ? { "ui.accent": "theme" } : {};
|
||||
return { status: "ok" };
|
||||
});
|
||||
const writer = createServerPrefsWriter(request, scope, true, { ok: true }, false);
|
||||
|
|
@ -319,7 +429,10 @@ describe("profile-bound appearance preferences", () => {
|
|||
const next = resetServerUiPref(key, state, scope, profileId);
|
||||
expect(next[key]).toBe(fallback);
|
||||
const delta = changedServerUiPrefs(previous, next);
|
||||
expect(delta).toEqual({ [key]: null });
|
||||
expect(delta).toEqual({
|
||||
[key]: null,
|
||||
...(key === "theme" ? { accent: "theme", fontUi: null, fontChat: null } : {}),
|
||||
});
|
||||
const committed = vi.fn();
|
||||
pushServerUiPrefs(writer, delta!, { profileId, canWrite: true, afterCommit: committed });
|
||||
await waitForFast(() => expect(committed).toHaveBeenCalledOnce());
|
||||
|
|
@ -329,7 +442,7 @@ describe("profile-bound appearance preferences", () => {
|
|||
delayed.resolve({ status: "ok", entries: savedEntries });
|
||||
await pending;
|
||||
expect(loadSettings()[key]).toBe(fallback);
|
||||
expect(entries).toEqual({});
|
||||
expect(entries).toEqual(key === "theme" ? { "ui.accent": "theme" } : {});
|
||||
|
||||
resetServerUiPrefsSync();
|
||||
const reloaded = createServerPrefsWriter(request, scope);
|
||||
|
|
@ -425,13 +538,19 @@ describe("profile-bound appearance preferences", () => {
|
|||
const state = resolveServerUiPrefState(config, key, scope, previous, { profileId });
|
||||
const next = resetServerUiPref(key, state, scope, profileId);
|
||||
expect(next[key]).toBe(resetValue);
|
||||
expect(changedServerUiPrefs(previous, next)).toEqual({ [key]: null });
|
||||
const delta = changedServerUiPrefs(previous, next);
|
||||
expect(delta).toEqual({
|
||||
[key]: null,
|
||||
...(key === "theme" ? { accent: "theme", fontUi: null, fontChat: null } : {}),
|
||||
});
|
||||
const afterCommit = vi.fn();
|
||||
pushServerUiPrefs(writer, { [key]: null }, { profileId, canWrite: true, afterCommit });
|
||||
pushServerUiPrefs(writer, delta!, { profileId, canWrite: true, afterCommit });
|
||||
await waitForFast(() => expect(afterCommit).toHaveBeenCalledOnce());
|
||||
expect(request).toHaveBeenLastCalledWith(
|
||||
key === "theme" ? "themes.set" : "users.prefs.set",
|
||||
key === "theme" ? { id: null } : { entries: { [preferenceKey]: null } },
|
||||
key === "theme"
|
||||
? { id: null, appearance: { accent: "theme", fontUi: null, fontChat: null } }
|
||||
: { entries: { [preferenceKey]: null } },
|
||||
);
|
||||
expect(
|
||||
resolveServerUiPrefState(config, key, scope, loadSettings(), { profileId }),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import { createStorageMock } from "../test-helpers/storage.ts";
|
||||
import { changedServerUiPrefs } from "./server-prefs-intent.ts";
|
||||
import {
|
||||
configWithPrefs,
|
||||
createServerPrefsWriter,
|
||||
|
|
@ -11,7 +12,6 @@ import {
|
|||
} from "./server-prefs.test-support.ts";
|
||||
import {
|
||||
applyServerUiPrefs,
|
||||
changedServerUiPrefs,
|
||||
flushServerUiPrefs,
|
||||
pushServerUiPrefs,
|
||||
resetServerUiPref,
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import { createDeferred } from "../../../test/helpers/promise.js";
|
|||
import { GatewayRequestError, type GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import { createStorageMock } from "../test-helpers/storage.ts";
|
||||
import { waitForFast } from "../test-helpers/wait-for.ts";
|
||||
import { changedServerUiPrefs } from "./server-prefs-intent.ts";
|
||||
import { extractServerUiPrefs } from "./server-prefs-state.ts";
|
||||
import { configWithPrefs, createServerPrefsWriter } from "./server-prefs.test-support.ts";
|
||||
import {
|
||||
applyServerUiPrefs,
|
||||
changedServerUiPrefs,
|
||||
flushServerUiPrefs,
|
||||
pushServerUiPrefs,
|
||||
resetServerUiPref,
|
||||
|
|
@ -149,7 +149,12 @@ describe("server pref extraction", () => {
|
|||
|
||||
const beforeReset = loadSettings();
|
||||
const afterReset = resetServerUiPref("theme", state);
|
||||
expect(changedServerUiPrefs(beforeReset, afterReset)).toEqual({ theme: null });
|
||||
expect(changedServerUiPrefs(beforeReset, afterReset)).toEqual({
|
||||
theme: null,
|
||||
accent: "theme",
|
||||
fontUi: null,
|
||||
fontChat: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import type { ConfigPatchAck } from "../lib/config/config-gateway-operations.ts"
|
|||
import type { RuntimeConfigCapability } from "../lib/config/runtime-config-capability.ts";
|
||||
import type { ApplicationGatewaySnapshot } from "./gateway.ts";
|
||||
import { hasOperatorWriteAccess } from "./operator-access.ts";
|
||||
import {
|
||||
requestServerUiPrefReset,
|
||||
resetServerUiPrefIntent,
|
||||
selectThemeSettings,
|
||||
} from "./server-prefs-intent.ts";
|
||||
import {
|
||||
loadProfileAppearancePrefs,
|
||||
rememberProfileAppearanceIdentity,
|
||||
|
|
@ -98,38 +103,8 @@ export function resolveServerUiPrefState<K extends SyncedPrefKey>(
|
|||
? { ...state, provenance: "synced" }
|
||||
: state;
|
||||
}
|
||||
/** Synced-key delta between two local settings snapshots, for the push path. */
|
||||
export function changedServerUiPrefs(previous: UiSettings, next: UiSettings): ServerUiPrefs | null {
|
||||
const prefs: ServerUiPrefs = {};
|
||||
for (const key of SYNCED_PREF_KEYS) {
|
||||
if (requestedDeviceLocalPrefResets.delete(key)) {
|
||||
continue;
|
||||
}
|
||||
if (requestedServerUiPrefResets.delete(key)) {
|
||||
(prefs as Record<string, unknown>)[key] = null;
|
||||
continue;
|
||||
}
|
||||
const specification = SYNCED_PREFS[key];
|
||||
const previousValue = specification.local(previous);
|
||||
const nextValue = specification.local(next);
|
||||
if (prefValuesEqual(previousValue, nextValue)) {
|
||||
continue;
|
||||
}
|
||||
if (nextValue === undefined) {
|
||||
// JSON merge patch removes keys via explicit null.
|
||||
if (specification.clearable) {
|
||||
(prefs as Record<string, unknown>)[key] = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
(prefs as Record<string, unknown>)[key] = nextValue;
|
||||
}
|
||||
return Object.keys(prefs).length > 0 ? prefs : null;
|
||||
}
|
||||
const CONFLICT_REDRAIN_DELAY_MS = 1_000;
|
||||
const MAX_CONFLICT_REDRAINS = 5;
|
||||
const requestedServerUiPrefResets = new Set<SyncedPrefKey>();
|
||||
const requestedDeviceLocalPrefResets = new Set<SyncedPrefKey>();
|
||||
let applyingServerPrefs = false;
|
||||
let pendingScope = "";
|
||||
let pendingPrefs: ServerUiPrefs | null = null;
|
||||
|
|
@ -280,8 +255,7 @@ export function resetServerUiPrefsSync() {
|
|||
lastReconciledScope = "";
|
||||
lastReconciledConfigObject = null;
|
||||
resetProfileAppearancePrefs();
|
||||
requestedServerUiPrefResets.clear();
|
||||
requestedDeviceLocalPrefResets.clear();
|
||||
resetServerUiPrefIntent();
|
||||
}
|
||||
|
||||
export function resetServerUiPref<K extends ResettableServerUiPrefKey>(
|
||||
|
|
@ -291,6 +265,10 @@ export function resetServerUiPref<K extends ResettableServerUiPrefKey>(
|
|||
profileId?: string | null,
|
||||
): UiSettings {
|
||||
const specification = SYNCED_PREFS[key];
|
||||
const applyReset = (patch: Partial<UiSettings>) =>
|
||||
key === "theme" && patch.theme !== undefined
|
||||
? selectThemeSettings(patch.theme)
|
||||
: patchSettings(patch);
|
||||
// Disconnected clients retain their last known profile for local cancellation.
|
||||
const activeProfile = isAppearancePref(key)
|
||||
? (profileId ?? resolveProfileAppearanceProfileId(scope))
|
||||
|
|
@ -307,16 +285,23 @@ export function resetServerUiPref<K extends ResettableServerUiPrefKey>(
|
|||
if (!write) {
|
||||
throw new Error(`Server UI preference cannot restore a retained local value: ${key}`);
|
||||
}
|
||||
cancelPendingKeys(effectiveScope, [key]);
|
||||
const patch = write(state.resetValue);
|
||||
const keys: SyncedPrefKey[] =
|
||||
key === "theme" && patch.theme !== loadSettings().theme
|
||||
? [key, "accent", "fontUi", "fontChat"]
|
||||
: [key];
|
||||
cancelPendingKeys(effectiveScope, keys);
|
||||
// Edits made after disconnect lose the profile and queue in the Gateway scope.
|
||||
if (effectiveScope !== scope) {
|
||||
cancelPendingKeys(scope, [key]);
|
||||
cancelPendingKeys(scope, keys);
|
||||
}
|
||||
updateRetainedLocalKeys(effectiveScope, [key], false);
|
||||
requestedDeviceLocalPrefResets.add(key);
|
||||
return patchSettings(write(state.resetValue));
|
||||
updateRetainedLocalKeys(effectiveScope, keys, false);
|
||||
for (const resetKey of keys) {
|
||||
requestServerUiPrefReset(resetKey, "device-local");
|
||||
}
|
||||
return applyReset(patch);
|
||||
}
|
||||
requestedServerUiPrefResets.add(key);
|
||||
requestServerUiPrefReset(key, "server");
|
||||
// The resolved state owns the reset target, including the Gateway fallback
|
||||
// while the profile is still loading. Config preferences use product defaults.
|
||||
if (state) {
|
||||
|
|
@ -325,10 +310,10 @@ export function resetServerUiPref<K extends ResettableServerUiPrefKey>(
|
|||
| ((value: SyncedPrefValue<K> | undefined) => Partial<UiSettings>)
|
||||
| undefined;
|
||||
if (write) {
|
||||
return patchSettings(write(state.resetValue));
|
||||
return applyReset(write(state.resetValue));
|
||||
}
|
||||
}
|
||||
return patchSettings(reset(loadSettings()));
|
||||
return applyReset(reset(loadSettings()));
|
||||
}
|
||||
export function applyServerUiPrefs(
|
||||
configObject: unknown,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { normalizeAgentId } from "@openclaw/normalization-core/agent-id";
|
|||
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { normalizeUniqueTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
|
||||
import { normalizeUiAppearancePreference } from "../../../packages/gateway-protocol/src/schema/ui-appearance-preferences.ts";
|
||||
import { DEFAULT_SIDEBAR_ENTRIES, normalizeSidebarEntries } from "../app-navigation.ts";
|
||||
import { configuredUiDevGateway } from "../dev-gateway.ts";
|
||||
import { isSupportedLocale } from "../i18n/index.ts";
|
||||
|
|
@ -142,9 +143,7 @@ export type ChatWorkspaceDock = (typeof CHAT_WORKSPACE_DOCKS)[number];
|
|||
export const normalizeChatWorkspaceDock = normalizeChoice(CHAT_WORKSPACE_DOCKS, "right");
|
||||
|
||||
export function normalizeAccentColor(value: unknown): string | undefined {
|
||||
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)
|
||||
? value.toLowerCase()
|
||||
: undefined;
|
||||
return normalizeUiAppearancePreference("ui.accent", value);
|
||||
}
|
||||
|
||||
export function normalizeTextScale(value: unknown, fallback: TextScaleStop = 100): TextScaleStop {
|
||||
|
|
|
|||
67
ui/src/e2e/appearance-prefs.test-support.ts
Normal file
67
ui/src/e2e/appearance-prefs.test-support.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { expect } from "vitest";
|
||||
import type { MockGatewayControls, MockGatewayRequest } from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
export function configResponse(prefs: Record<string, unknown>, hash: string) {
|
||||
const config = { ui: { prefs } };
|
||||
return {
|
||||
appliedConfigHash: hash,
|
||||
config,
|
||||
configRevisionHash: hash,
|
||||
hash,
|
||||
issues: [],
|
||||
raw: JSON.stringify(config),
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
expect(value, label).toBeTruthy();
|
||||
expect(typeof value, label).toBe("object");
|
||||
expect(Array.isArray(value), label).toBe(false);
|
||||
// SAFETY: The assertions above establish a present, non-array protocol record.
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function patchPrefs(request: MockGatewayRequest): Record<string, unknown> {
|
||||
const params = requireRecord(request.params, "config.patch params");
|
||||
expect(typeof params.raw).toBe("string");
|
||||
const parsed = requireRecord(JSON.parse(String(params.raw)), "config.patch raw");
|
||||
const ui = requireRecord(parsed.ui, "config.patch ui");
|
||||
return requireRecord(ui.prefs, "config.patch ui.prefs");
|
||||
}
|
||||
|
||||
export async function waitForRequestCount(
|
||||
gateway: MockGatewayControls,
|
||||
method: string,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests(method)).length, { timeout: 10_000 })
|
||||
.toBe(count);
|
||||
}
|
||||
|
||||
export async function resetSyncedPreference(options: {
|
||||
click: () => Promise<void>;
|
||||
expectedKey: string;
|
||||
expectedPrefs?: Record<string, unknown>;
|
||||
gateway: MockGatewayControls;
|
||||
hash: string;
|
||||
remainingPrefs: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const patchCount = (await options.gateway.getRequests("config.patch")).length;
|
||||
const configGetCount = (await options.gateway.getRequests("config.get")).length;
|
||||
await options.gateway.setMethodResponse(
|
||||
"config.get",
|
||||
configResponse(options.remainingPrefs, options.hash),
|
||||
);
|
||||
|
||||
await options.click();
|
||||
await waitForRequestCount(options.gateway, "config.patch", patchCount + 1);
|
||||
const patches = await options.gateway.getRequests("config.patch");
|
||||
expect(patchPrefs(patches[patchCount]!)).toEqual(
|
||||
options.expectedPrefs ?? {
|
||||
[options.expectedKey]: null,
|
||||
},
|
||||
);
|
||||
await waitForRequestCount(options.gateway, "config.get", configGetCount + 1);
|
||||
}
|
||||
|
|
@ -10,10 +10,15 @@ import {
|
|||
createControlUiMockBootstrapConfig,
|
||||
installMockGateway,
|
||||
waitForControlUiSettingsTakeover,
|
||||
type MockGatewayControls,
|
||||
type MockGatewayRequest,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createTweakcnThemePayload } from "../test-helpers/custom-theme.ts";
|
||||
import {
|
||||
configResponse,
|
||||
patchPrefs,
|
||||
resetSyncedPreference,
|
||||
waitForRequestCount,
|
||||
} from "./appearance-prefs.test-support.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
|
|
@ -34,34 +39,6 @@ function settingsStorageKey(): string {
|
|||
return controlUiBundledSettingsStorageKey(suite.server.baseUrl);
|
||||
}
|
||||
|
||||
function configResponse(prefs: Record<string, unknown>, hash: string) {
|
||||
const config = { ui: { prefs } };
|
||||
return {
|
||||
appliedConfigHash: hash,
|
||||
config,
|
||||
configRevisionHash: hash,
|
||||
hash,
|
||||
issues: [],
|
||||
raw: JSON.stringify(config),
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
expect(value, label).toBeTruthy();
|
||||
expect(typeof value, label).toBe("object");
|
||||
expect(Array.isArray(value), label).toBe(false);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function patchPrefs(request: MockGatewayRequest): Record<string, unknown> {
|
||||
const params = requireRecord(request.params, "config.patch params");
|
||||
expect(typeof params.raw).toBe("string");
|
||||
const parsed = requireRecord(JSON.parse(String(params.raw)), "config.patch raw");
|
||||
const ui = requireRecord(parsed.ui, "config.patch ui");
|
||||
return requireRecord(ui.prefs, "config.patch ui.prefs");
|
||||
}
|
||||
|
||||
function settingsRow(page: Page, title: string): Locator {
|
||||
return page
|
||||
.locator(".settings-row")
|
||||
|
|
@ -75,39 +52,6 @@ async function selectValue(locator: Locator): Promise<string> {
|
|||
);
|
||||
}
|
||||
|
||||
async function waitForRequestCount(
|
||||
gateway: MockGatewayControls,
|
||||
method: string,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests(method)).length, { timeout: 10_000 })
|
||||
.toBe(count);
|
||||
}
|
||||
|
||||
async function resetSyncedPreference(options: {
|
||||
click: () => Promise<void>;
|
||||
expectedKey: string;
|
||||
gateway: MockGatewayControls;
|
||||
hash: string;
|
||||
remainingPrefs: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const patchCount = (await options.gateway.getRequests("config.patch")).length;
|
||||
const configGetCount = (await options.gateway.getRequests("config.get")).length;
|
||||
await options.gateway.setMethodResponse(
|
||||
"config.get",
|
||||
configResponse(options.remainingPrefs, options.hash),
|
||||
);
|
||||
|
||||
await options.click();
|
||||
await waitForRequestCount(options.gateway, "config.patch", patchCount + 1);
|
||||
const patches = await options.gateway.getRequests("config.patch");
|
||||
expect(patchPrefs(patches[patchCount] as MockGatewayRequest)).toEqual({
|
||||
[options.expectedKey]: null,
|
||||
});
|
||||
await waitForRequestCount(options.gateway, "config.get", configGetCount + 1);
|
||||
}
|
||||
|
||||
async function readPersistedSettings(page: Page): Promise<Record<string, unknown>> {
|
||||
return page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
|
|
@ -311,7 +255,7 @@ suite.define(() => {
|
|||
remainingPrefs: withoutLocale,
|
||||
});
|
||||
|
||||
const withoutTheme = { ...withoutLocale };
|
||||
const withoutTheme: Record<string, unknown> = { ...withoutLocale, accent: "theme" };
|
||||
delete withoutTheme.theme;
|
||||
await resetSyncedPreference({
|
||||
click: () =>
|
||||
|
|
@ -320,6 +264,7 @@ suite.define(() => {
|
|||
.click()
|
||||
.then(() => undefined),
|
||||
expectedKey: "theme",
|
||||
expectedPrefs: { theme: null, accent: "theme" },
|
||||
gateway,
|
||||
hash: "appearance-defaults-3",
|
||||
remainingPrefs: withoutTheme,
|
||||
|
|
@ -498,13 +443,15 @@ suite.define(() => {
|
|||
.click()
|
||||
.then(() => undefined),
|
||||
expectedKey: "theme",
|
||||
expectedPrefs: { theme: null, accent: "theme" },
|
||||
gateway,
|
||||
hash: "appearance-accent-3",
|
||||
remainingPrefs: { accent: mintAccent },
|
||||
remainingPrefs: { accent: "theme" },
|
||||
});
|
||||
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe("dark");
|
||||
await expect.poll(() => readAccentPresentation(page)).toMatchObject({ accent: mintAccent });
|
||||
await expect.poll(() => mintPreset.getAttribute("aria-pressed")).toBe("true");
|
||||
await expect.poll(() => readAccentPresentation(page)).toMatchObject({ accent: "#ff5c5c" });
|
||||
await expect.poll(() => readPersistedSettings(page)).toMatchObject({ accent: "theme" });
|
||||
await expect.poll(() => mintPreset.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
await gateway.setMethodResponse(
|
||||
"config.get",
|
||||
|
|
@ -598,7 +545,7 @@ suite.define(() => {
|
|||
await waitForRequestCount(gateway, "config.patch", 1);
|
||||
expect(
|
||||
patchPrefs((await gateway.getRequests("config.patch"))[0] as MockGatewayRequest),
|
||||
).toEqual({ theme: "knot" });
|
||||
).toEqual({ theme: "knot", accent: "theme" });
|
||||
await gateway.rejectDeferred("config.patch", {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "mock validation failure",
|
||||
|
|
@ -838,7 +785,7 @@ suite.define(() => {
|
|||
|
||||
await gateway.setMethodResponse(
|
||||
"config.get",
|
||||
configResponse({ theme: "custom" }, "custom-theme-imported-2"),
|
||||
configResponse({ theme: "custom", accent: "theme" }, "custom-theme-imported-2"),
|
||||
);
|
||||
await importer.locator("input").fill("https://tweakcn.com/themes/retry-theme");
|
||||
await importer.locator("button.primary").click();
|
||||
|
|
@ -853,7 +800,7 @@ suite.define(() => {
|
|||
expect(importedAccent.accent).toBe(createTweakcnThemePayload().cssVars.dark.accent);
|
||||
await waitForRequestCount(gateway, "config.patch", 1);
|
||||
const [themePatch] = await gateway.getRequests("config.patch");
|
||||
expect(patchPrefs(themePatch!)).toEqual({ theme: "custom" });
|
||||
expect(patchPrefs(themePatch!)).toEqual({ theme: "custom", accent: "theme" });
|
||||
await captureViewport(page, "08-custom-theme-imported.png");
|
||||
|
||||
await page.reload();
|
||||
|
|
|
|||
|
|
@ -265,7 +265,10 @@ suite.define(() => {
|
|||
const context = await createContext();
|
||||
const page = await context.newPage();
|
||||
const initial = configResponse({ theme: "claw", themeMode: "system" }, "prefs-a-1");
|
||||
const committed = configResponse({ theme: "knot", themeMode: "system" }, "prefs-a-2");
|
||||
const committed = configResponse(
|
||||
{ theme: "knot", themeMode: "system", accent: "theme" },
|
||||
"prefs-a-2",
|
||||
);
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: { "config.get": initial },
|
||||
});
|
||||
|
|
@ -284,7 +287,7 @@ suite.define(() => {
|
|||
});
|
||||
|
||||
const patch = await gateway.waitForRequest("config.patch");
|
||||
expect(patchPrefs(patch)).toEqual({ theme: "knot" });
|
||||
expect(patchPrefs(patch)).toEqual({ theme: "knot", accent: "theme" });
|
||||
// Reconnect owns one authoritative read even while the pending LWW preference shadows it.
|
||||
await waitForRequestCount(gateway, "config.get", configGetsBeforeEdit + 1);
|
||||
|
||||
|
|
@ -301,7 +304,10 @@ suite.define(() => {
|
|||
const context = await createContext();
|
||||
const page = await context.newPage();
|
||||
const initial = configResponse({ theme: "claw", themeMode: "system" }, "prefs-scope-1");
|
||||
const committed = configResponse({ theme: "knot", themeMode: "system" }, "prefs-scope-2");
|
||||
const committed = configResponse(
|
||||
{ theme: "knot", themeMode: "system", accent: "theme" },
|
||||
"prefs-scope-2",
|
||||
);
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: { "config.get": initial },
|
||||
});
|
||||
|
|
@ -328,7 +334,7 @@ suite.define(() => {
|
|||
});
|
||||
|
||||
const patch = await gateway.waitForRequest("config.patch");
|
||||
expect(patchPrefs(patch)).toEqual({ theme: "knot" });
|
||||
expect(patchPrefs(patch)).toEqual({ theme: "knot", accent: "theme" });
|
||||
await gateway.resolveDeferred("config.patch", committed);
|
||||
await expectThemeActive(page, "knot");
|
||||
await expect
|
||||
|
|
@ -368,7 +374,9 @@ suite.define(() => {
|
|||
await proxyReconnect(pageA, gatewayA, async () => {
|
||||
await themeCard(pageA, "knot").click();
|
||||
await expectThemeActive(pageA, "knot");
|
||||
expect(await readPendingPrefStorage(pageA)).toEqual([{ theme: "knot" }]);
|
||||
expect(await readPendingPrefStorage(pageA)).toEqual([
|
||||
{ theme: "knot", accent: "theme", fontUi: null, fontChat: null },
|
||||
]);
|
||||
await themeCard(pageB, "dash").click();
|
||||
await expectThemeActive(pageB, "dash");
|
||||
expect(await readPendingPrefStorage(pageB)).toEqual([]);
|
||||
|
|
@ -411,7 +419,7 @@ suite.define(() => {
|
|||
await themeCard(pageA, "knot").click();
|
||||
const patchA = await gatewayA.waitForRequest("config.patch");
|
||||
const prefsA = patchPrefs(patchA);
|
||||
expect(prefsA).toEqual({ theme: "knot" });
|
||||
expect(prefsA).toEqual({ theme: "knot", accent: "theme" });
|
||||
const themeCommitted = configResponse(prefsA, "prefs-b-2");
|
||||
await gatewayA.setMethodResponse("config.get", themeCommitted);
|
||||
await gatewayA.resolveDeferred("config.patch", themeCommitted);
|
||||
|
|
@ -468,7 +476,7 @@ suite.define(() => {
|
|||
|
||||
await themeCard(page, "knot").click();
|
||||
const patch = await gateway.waitForRequest("config.patch");
|
||||
expect(patchPrefs(patch)).toEqual({ theme: "knot" });
|
||||
expect(patchPrefs(patch)).toEqual({ theme: "knot", accent: "theme" });
|
||||
|
||||
const serverChanged = configResponse({ locale: "de", theme: "claw" }, "prefs-c-2");
|
||||
await gateway.setMethodResponse("config.get", serverChanged);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ function themeConfigResponse(
|
|||
const config = {
|
||||
ui: { prefs: { ...(family === "claw" ? {} : { theme: family }), themeMode: mode, accent } },
|
||||
};
|
||||
const hash = `theme-contrast-${family}-${mode}`;
|
||||
const hash = `theme-contrast-${family}-${mode}-${accent ?? "default"}`;
|
||||
return {
|
||||
appliedConfigHash: hash,
|
||||
config,
|
||||
|
|
@ -184,14 +184,14 @@ suite.define(() => {
|
|||
await selectedCard.waitFor({ state: "visible" });
|
||||
await gateway.waitForRequest("config.get");
|
||||
const initialConfigGets = (await gateway.getRequests("config.get")).length;
|
||||
const committed = themeConfigResponse(family, mode, accent);
|
||||
const committed = themeConfigResponse(family, mode, "theme");
|
||||
await gateway.deferNext("config.patch");
|
||||
await selectedCard.click();
|
||||
const patch = await gateway.waitForRequest("config.patch");
|
||||
const raw = (patch.params as { raw?: unknown } | undefined)?.raw;
|
||||
expect(typeof raw).toBe("string");
|
||||
expect(JSON.parse(String(raw))).toMatchObject({
|
||||
ui: { prefs: { theme: family === "claw" ? null : family } },
|
||||
ui: { prefs: { theme: family === "claw" ? null : family, accent: "theme" } },
|
||||
});
|
||||
|
||||
// Theme clicks apply immediately; the eventual Gateway acknowledgement must not revert them.
|
||||
|
|
@ -212,6 +212,27 @@ suite.define(() => {
|
|||
.poll(() => selectedCard.getAttribute("class"))
|
||||
.toContain("settings-theme-card--active");
|
||||
|
||||
// Reapply each extreme through the real picker after theme defaults,
|
||||
// rather than accidentally dropping custom-accent contrast coverage.
|
||||
if (accent) {
|
||||
await gateway.setMethodResponse("config.get", themeConfigResponse(family, mode, accent));
|
||||
await page.locator("[data-accent-custom]").fill(accent);
|
||||
const accentPatch = await gateway.waitForRequest("config.patch", { after: 1 });
|
||||
// SAFETY: This is the config.patch request emitted by the exercised picker;
|
||||
// the parsed payload below is checked against its complete prefs contract.
|
||||
expect(JSON.parse((accentPatch.params as { raw: string }).raw)).toEqual({
|
||||
ui: { prefs: { accent } },
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("config.get")).length)
|
||||
.toBe(initialConfigGets + 2);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.style.getPropertyValue("--accent")),
|
||||
)
|
||||
.toBe(accent);
|
||||
}
|
||||
|
||||
const visibleDescription = page.locator(".settings-section__desc").first();
|
||||
await visibleDescription.waitFor({ state: "visible" });
|
||||
|
||||
|
|
|
|||
176
ui/src/e2e/theme-selection-defaults.e2e.test.ts
Normal file
176
ui/src/e2e/theme-selection-defaults.e2e.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { expect, it } from "vitest";
|
||||
import {
|
||||
controlUiBundledSettingsStorageKey,
|
||||
installMockGateway,
|
||||
waitForControlUiSettingsTakeover,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { selectPickerValue } from "../test-helpers/select-picker-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({ name: "Control UI theme selection defaults" });
|
||||
|
||||
suite.define(() => {
|
||||
it("resets design overrides only on explicit theme selection and persists the result through profile reads", async () => {
|
||||
await suite.withPage(
|
||||
{
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
reducedMotion: "reduce",
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
},
|
||||
async ({ page }) => {
|
||||
const profileId = "theme-defaults-user";
|
||||
const config = {
|
||||
ui: {
|
||||
seamColor: "#123456",
|
||||
prefs: { theme: "claw", accent: "#abcdef", locale: "en", chatShowThinking: false },
|
||||
},
|
||||
};
|
||||
const initialEntries = {
|
||||
"ui.theme": "dash",
|
||||
"ui.themeMode": "dark",
|
||||
"ui.fontUi": "jetbrains-mono",
|
||||
"ui.fontChat": "jetbrains-mono",
|
||||
"ui.accent": "#5b9cf6",
|
||||
};
|
||||
const gateway = await installMockGateway(page, {
|
||||
presenceUsers: [{ id: profileId, name: "Theme demo", self: true }],
|
||||
heldMethods: ["users.prefs.get"],
|
||||
methodResponses: {
|
||||
"config.get": {
|
||||
config,
|
||||
raw: JSON.stringify(config),
|
||||
hash: "theme-defaults",
|
||||
valid: true,
|
||||
issues: [],
|
||||
},
|
||||
"users.prefs.set": { status: "ok" },
|
||||
},
|
||||
});
|
||||
const read = () =>
|
||||
page.evaluate(() => ({
|
||||
theme: document.documentElement.dataset.theme,
|
||||
ui: getComputedStyle(document.body).fontFamily,
|
||||
chat: getComputedStyle(document.documentElement).getPropertyValue("--font-chat").trim(),
|
||||
accent: getComputedStyle(document.documentElement).getPropertyValue("--accent").trim(),
|
||||
}));
|
||||
const storageKey = controlUiBundledSettingsStorageKey(suite.server.baseUrl);
|
||||
const mirror = () =>
|
||||
page.evaluate((key) => JSON.parse(localStorage.getItem(key) ?? "{}"), storageKey);
|
||||
const respondProfile = async (entries: Record<string, string>, after = 0) => {
|
||||
await gateway.waitForRequest("users.prefs.get", { after });
|
||||
await gateway.resolveDeferred("users.prefs.get", { status: "ok", entries });
|
||||
};
|
||||
await page.goto(suite.server.baseUrl + "settings/appearance");
|
||||
await respondProfile(initialEntries);
|
||||
await waitForControlUiSettingsTakeover(page);
|
||||
// Boot and profile arrival retain existing customization.
|
||||
await expect.poll(read).toMatchObject({
|
||||
theme: "dash",
|
||||
accent: "#5b9cf6",
|
||||
ui: expect.stringContaining("JetBrains Mono"),
|
||||
chat: expect.stringContaining("JetBrains Mono"),
|
||||
});
|
||||
await page.locator(".settings-theme-card--absolutely").click();
|
||||
await expect.poll(read).toMatchObject({
|
||||
theme: "absolutely",
|
||||
accent: "#d97757",
|
||||
ui: expect.stringContaining("Space Grotesk"),
|
||||
chat: expect.stringContaining("Lora"),
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("themes.set")).map((r) => r.params))
|
||||
.toContainEqual({
|
||||
id: "absolutely",
|
||||
appearance: { accent: "theme", fontUi: null, fontChat: null },
|
||||
});
|
||||
expect(await gateway.getRequests("config.patch")).toEqual([]);
|
||||
await expect.poll(mirror).toMatchObject({
|
||||
theme: "absolutely",
|
||||
themeMode: "dark",
|
||||
accent: "theme",
|
||||
locale: "en",
|
||||
chatShowThinking: false,
|
||||
});
|
||||
expect(await mirror()).not.toHaveProperty("fontUi");
|
||||
expect(await mirror()).not.toHaveProperty("fontChat");
|
||||
expect(
|
||||
await page.locator(".settings-accent-swatch--custom").getAttribute("class"),
|
||||
).not.toContain("--active");
|
||||
expect(await page.locator("#settings-accent-status").textContent()).toContain(
|
||||
"Using theme accent",
|
||||
);
|
||||
expect(await page.locator("[data-accent-custom]").inputValue()).toMatch(/^#[0-9a-f]{6}$/u);
|
||||
|
||||
const selected = { "ui.theme": "absolutely", "ui.themeMode": "dark", "ui.accent": "theme" };
|
||||
// Reconcile a fresh authoritative snapshot after the outgoing atomic reset.
|
||||
let reads = (await gateway.getRequests("users.prefs.get")).length;
|
||||
await gateway.deferNext("users.prefs.get");
|
||||
await gateway.emitGatewayEvent("users.prefs.changed", {
|
||||
profileId,
|
||||
keys: Object.keys(initialEntries),
|
||||
});
|
||||
await respondProfile(selected, reads);
|
||||
await page.reload();
|
||||
// The mirror already paints defaults while the profile read is held.
|
||||
await expect.poll(read).toMatchObject({ theme: "absolutely", accent: "#d97757" });
|
||||
await respondProfile(selected);
|
||||
await waitForControlUiSettingsTakeover(page);
|
||||
await expect.poll(read).toMatchObject({
|
||||
ui: expect.stringContaining("Space Grotesk"),
|
||||
chat: expect.stringContaining("Lora"),
|
||||
accent: "#d97757",
|
||||
});
|
||||
await page.getByRole("radio", { name: "Light", exact: true }).click();
|
||||
await expect.poll(read).toMatchObject({ theme: "absolutely-light", accent: "#a8452a" });
|
||||
|
||||
await selectPickerValue(
|
||||
page.locator("openclaw-select-picker:has(#settings-font-ui)"),
|
||||
"geist",
|
||||
);
|
||||
await selectPickerValue(
|
||||
page.locator("openclaw-select-picker:has(#settings-font-chat)"),
|
||||
"geist",
|
||||
);
|
||||
await page.locator('[data-accent-preset="blue"]').click();
|
||||
await page.locator(".settings-theme-card--absolutely").click();
|
||||
await page.getByRole("radio", { name: "Dark", exact: true }).click();
|
||||
await expect.poll(read).toMatchObject({
|
||||
theme: "absolutely",
|
||||
ui: expect.stringContaining("Geist"),
|
||||
chat: expect.stringContaining("Geist"),
|
||||
accent: "#5b9cf6",
|
||||
});
|
||||
const custom = {
|
||||
...selected,
|
||||
"ui.fontUi": "geist",
|
||||
"ui.fontChat": "geist",
|
||||
"ui.accent": "#5b9cf6",
|
||||
};
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("users.prefs.set")).map((r) => r.params))
|
||||
.toContainEqual({ entries: { "ui.accent": "#5b9cf6" } });
|
||||
reads = (await gateway.getRequests("users.prefs.get")).length;
|
||||
await gateway.deferNext("users.prefs.get");
|
||||
await gateway.closeLatest();
|
||||
await respondProfile(custom, reads);
|
||||
await expect.poll(read).toMatchObject({
|
||||
theme: "absolutely",
|
||||
ui: expect.stringContaining("Geist"),
|
||||
chat: expect.stringContaining("Geist"),
|
||||
accent: "#5b9cf6",
|
||||
});
|
||||
await page.reload();
|
||||
await respondProfile(custom);
|
||||
await waitForControlUiSettingsTakeover(page);
|
||||
await expect.poll(read).toMatchObject({
|
||||
theme: "absolutely",
|
||||
ui: expect.stringContaining("Geist"),
|
||||
chat: expect.stringContaining("Geist"),
|
||||
accent: "#5b9cf6",
|
||||
});
|
||||
await expect.poll(mirror).toMatchObject({ locale: "en", chatShowThinking: false });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -957,7 +957,7 @@ const enSettings = {
|
|||
appearance: {
|
||||
intro: "Theme, chat, and sidebar preferences for this Control UI client.",
|
||||
theme: "Theme",
|
||||
chooseTheme: "Choose a theme family.",
|
||||
chooseTheme: "Choosing a different theme resets its fonts and accent colors.",
|
||||
themeUnavailable: "{id} is unavailable. Using Claw until the theme becomes available again.",
|
||||
typography: "Typography",
|
||||
fonts: {
|
||||
|
|
@ -986,6 +986,7 @@ const enSettings = {
|
|||
accent: "Accent color",
|
||||
accentHint: "Choose an accent color for buttons, highlights, and other controls.",
|
||||
customAccent: "Custom color",
|
||||
usingThemeAccent: "Using theme accent",
|
||||
usingAccent: "Using {value}",
|
||||
accents: {
|
||||
default: "Theme default",
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { createDeferred as deferred } from "../../../../test/helpers/promise.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { changedServerUiPrefs } from "../../app/server-prefs-intent.ts";
|
||||
import { createServerPrefsWriter } from "../../app/server-prefs.test-support.ts";
|
||||
import {
|
||||
applyServerUiPrefs,
|
||||
changedServerUiPrefs,
|
||||
flushServerUiPrefs,
|
||||
pushServerUiPrefs,
|
||||
refreshProfileAppearancePrefs,
|
||||
|
|
@ -170,7 +170,12 @@ describe("ConfigPage synced preference provenance", () => {
|
|||
page.resetSyncedAppearancePref("theme");
|
||||
|
||||
expect(page.settings.theme).toBe("dash");
|
||||
expect(changedServerUiPrefs(beforeReset, page.settings)).toEqual({ theme: null });
|
||||
expect(changedServerUiPrefs(beforeReset, page.settings)).toEqual({
|
||||
theme: null,
|
||||
accent: "theme",
|
||||
fontUi: null,
|
||||
fontChat: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { applicationContext, type ApplicationContext } from "../../app/context.t
|
|||
import { hasNativeBrowserBridge } from "../../app/native-browser-host.ts";
|
||||
import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
|
||||
import { isBrowserPanelAvailable } from "../../app/panel-availability.ts";
|
||||
import { selectThemeSettings } from "../../app/server-prefs-intent.ts";
|
||||
import { isAppearancePref, type ResettableServerUiPrefKey } from "../../app/server-prefs-state.ts";
|
||||
import { resetServerUiPref, resolveServerUiPrefState } from "../../app/server-prefs.ts";
|
||||
import {
|
||||
|
|
@ -765,8 +766,10 @@ export class ConfigPage extends OpenClawLightDomElement {
|
|||
};
|
||||
}
|
||||
|
||||
private applySettings(patch: Partial<UiSettings>) {
|
||||
this.settings = patchSettings(patch);
|
||||
private applySettings(patch: Partial<UiSettings>, selectedTheme?: ThemeName) {
|
||||
this.settings = selectedTheme
|
||||
? selectThemeSettings(selectedTheme, patch)
|
||||
: patchSettings(patch);
|
||||
applyTextScale(this.settings.textScale);
|
||||
// theme.refresh() also republishes non-theme appearance prefs (text
|
||||
// scale, lobster pet visits/sounds) to app-host subscribers.
|
||||
|
|
@ -851,13 +854,12 @@ export class ConfigPage extends OpenClawLightDomElement {
|
|||
const preference = this.currentSyncedPref("theme");
|
||||
const reset = preference.overridden && theme === preference.resetValue;
|
||||
this.customThemeImportOwner.recordActivation(reset ? null : theme);
|
||||
const currentTheme = resolveTheme(this.settings.theme, this.settings.themeMode);
|
||||
startThemeTransition({
|
||||
currentTheme,
|
||||
currentTheme: resolveTheme(this.settings.theme, this.settings.themeMode),
|
||||
nextTheme: resolveTheme(theme, this.settings.themeMode),
|
||||
context,
|
||||
applyTheme: () =>
|
||||
reset ? this.resetSyncedAppearancePref("theme") : this.applySettings({ theme }),
|
||||
reset ? this.resetSyncedAppearancePref("theme") : this.applySettings({}, theme),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -906,10 +908,7 @@ export class ConfigPage extends OpenClawLightDomElement {
|
|||
hasCustomTheme: Boolean(this.settings.customTheme),
|
||||
load: importCustomThemeFromUrl,
|
||||
apply: (customTheme, activate) =>
|
||||
this.applySettings({
|
||||
customTheme,
|
||||
theme: activate ? "custom" : this.settings.theme,
|
||||
}),
|
||||
this.applySettings({ customTheme }, activate ? "custom" : this.settings.theme),
|
||||
messages: {
|
||||
blocked: (reason) => t(reason === "loading" ? "common.loading" : "common.unsavedChanges"),
|
||||
imported: (label) => t("configPage.themeImported", { name: label }),
|
||||
|
|
@ -920,10 +919,10 @@ export class ConfigPage extends OpenClawLightDomElement {
|
|||
private clearCustomTheme() {
|
||||
this.customThemeImportOwner.clear({
|
||||
apply: () =>
|
||||
this.applySettings({
|
||||
theme: this.settings.theme === "custom" ? "claw" : this.settings.theme,
|
||||
customTheme: undefined,
|
||||
}),
|
||||
this.applySettings(
|
||||
{ customTheme: undefined },
|
||||
this.settings.theme === "custom" ? "claw" : this.settings.theme,
|
||||
),
|
||||
message: t("configPage.themeRemoved"),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,25 +229,35 @@ export function renderAppearanceSection(
|
|||
// swatch permanently unselectable and its reset click without a visible effect.
|
||||
// Accepted cost: an override equal to its reset target reads as inherited
|
||||
// until the two diverge, when the swatches correct themselves.
|
||||
const defaultAccentSelected = props.accent === props.accentResetValue;
|
||||
const themeAccentSelected = props.accent === "theme";
|
||||
const accentColor = themeAccentSelected ? undefined : props.accent;
|
||||
const defaultAccentSelected =
|
||||
props.accent === props.accentResetValue ||
|
||||
(themeAccentSelected && props.accentResetValue === undefined);
|
||||
// Preview the accent a reset lands on, never var(--accent): the live override
|
||||
// would render this swatch as a duplicate of the selected preset.
|
||||
const themeAccentColor = props.accentResetValue ?? "var(--theme-chip-accent)";
|
||||
const themeAccentColor =
|
||||
props.accentResetValue && props.accentResetValue !== "theme"
|
||||
? props.accentResetValue
|
||||
: "var(--theme-chip-accent)";
|
||||
const customAccentSelected = Boolean(
|
||||
!defaultAccentSelected &&
|
||||
!themeAccentSelected &&
|
||||
props.accent &&
|
||||
!ACCENT_PRESETS.some((preset) => preset.hex === props.accent),
|
||||
);
|
||||
const selectedAccentPreset = ACCENT_PRESETS.find(
|
||||
(preset) => preset.hex !== undefined && preset.hex === props.accent,
|
||||
);
|
||||
const accentSelectionStatus = defaultAccentSelected
|
||||
? null
|
||||
: t("configView.appearance.usingAccent", {
|
||||
value: selectedAccentPreset
|
||||
? t(selectedAccentPreset.labelKey)
|
||||
: t("configView.appearance.customAccent"),
|
||||
});
|
||||
const accentSelectionStatus = themeAccentSelected
|
||||
? t("configView.appearance.usingThemeAccent")
|
||||
: defaultAccentSelected
|
||||
? null
|
||||
: t("configView.appearance.usingAccent", {
|
||||
value: selectedAccentPreset
|
||||
? t(selectedAccentPreset.labelKey)
|
||||
: t("configView.appearance.customAccent"),
|
||||
});
|
||||
return html`
|
||||
<div class="settings-page">
|
||||
${renderLanguageSection(props)}
|
||||
|
|
@ -484,9 +494,9 @@ export function renderAppearanceSection(
|
|||
customAccentSelected ? "settings-accent-swatch--active" : ""
|
||||
}"
|
||||
style=${styleMap({
|
||||
"--settings-accent-swatch": props.accent ?? ACCENT_PRESETS[1].hex,
|
||||
"--settings-accent-swatch": accentColor ?? ACCENT_PRESETS[1].hex,
|
||||
"--settings-accent-swatch-ink": controlUiAccentInk(
|
||||
props.accent ?? ACCENT_PRESETS[1].hex,
|
||||
accentColor ?? ACCENT_PRESETS[1].hex,
|
||||
),
|
||||
})}
|
||||
>
|
||||
|
|
@ -497,7 +507,7 @@ export function renderAppearanceSection(
|
|||
aria-label=${t("configView.appearance.customAccent")}
|
||||
aria-describedby="settings-accent-status"
|
||||
title=${t("configView.appearance.customAccent")}
|
||||
.value=${props.accent ?? ACCENT_PRESETS[1].hex}
|
||||
.value=${accentColor ?? ACCENT_PRESETS[1].hex}
|
||||
@input=${(event: Event & { currentTarget: HTMLInputElement }) =>
|
||||
props.setAccent(event.currentTarget.value)}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue