From db02d741e9dcacd548b578ae056a9c8e841c4ea2 Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Sat, 19 Sep 2026 16:56:37 -0700 Subject: [PATCH] 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> --- config/assertion-safety-baseline.txt | 2 +- docs/.generated/config-baseline.sha256 | 4 +- docs/concepts/user-model.md | 2 +- docs/gateway/config-browser-ui-desktop.md | 4 +- docs/web/control-ui/settings.md | 8 +- .../src/schema/ui-appearance-preferences.ts | 3 +- .../src/schema/users-prefs.test.ts | 3 + src/config/config-accent.test-support.ts | 8 + src/config/config-misc.test.ts | 9 +- src/config/schema.help.agents.ts | 2 +- src/config/types.openclaw.ts | 2 +- src/config/zod-schema.root-shape.ts | 2 +- .../handlers/config-accent.test-support.ts | 24 +++ src/gateway/talk/handlers/index.test.ts | 24 +-- src/gateway/talk/handlers/index.ts | 2 +- ui/src/app/app-host.ts | 15 +- ui/src/app/control-ui-presentation.test.ts | 6 + ui/src/app/control-ui-presentation.ts | 5 +- ui/src/app/server-prefs-intent.ts | 83 +++++++++ ui/src/app/server-prefs.profile.test.ts | 135 +++++++++++++- ui/src/app/server-prefs.read-only.test.ts | 2 +- ui/src/app/server-prefs.test.ts | 9 +- ui/src/app/server-prefs.ts | 65 +++---- ui/src/app/settings.ts | 5 +- ui/src/e2e/appearance-prefs.test-support.ts | 67 +++++++ .../appearance-settings-defaults.e2e.test.ts | 85 ++------- .../e2e/settings-prefs-reconnect.e2e.test.ts | 22 ++- ui/src/e2e/theme-muted-contrast.e2e.test.ts | 27 ++- .../e2e/theme-selection-defaults.e2e.test.ts | 176 ++++++++++++++++++ ui/src/i18n/locales/en-settings.ts | 3 +- ui/src/pages/config/config-page.test.ts | 9 +- ui/src/pages/config/config-page.ts | 25 ++- ui/src/pages/config/view-appearance.ts | 34 ++-- 33 files changed, 663 insertions(+), 209 deletions(-) create mode 100644 src/config/config-accent.test-support.ts create mode 100644 src/gateway/talk/handlers/config-accent.test-support.ts create mode 100644 ui/src/app/server-prefs-intent.ts create mode 100644 ui/src/e2e/appearance-prefs.test-support.ts create mode 100644 ui/src/e2e/theme-selection-defaults.e2e.test.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 2357b414d878..9b11796225d9 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index b552b31ee359..102958bfc212 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/concepts/user-model.md b/docs/concepts/user-model.md index 2ebc9071a35b..e2da11c53d34 100644 --- a/docs/concepts/user-model.md +++ b/docs/concepts/user-model.md @@ -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 diff --git a/docs/gateway/config-browser-ui-desktop.md b/docs/gateway/config-browser-ui-desktop.md index 574562aaf42f..d61aa00a2d3d 100644 --- a/docs/gateway/config-browser-ui-desktop.md +++ b/docs/gateway/config-browser-ui-desktop.md @@ -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 diff --git a/docs/web/control-ui/settings.md b/docs/web/control-ui/settings.md index 79b3092c808a..6ed8a1be20b2 100644 --- a/docs/web/control-ui/settings.md +++ b/docs/web/control-ui/settings.md @@ -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. diff --git a/packages/gateway-protocol/src/schema/ui-appearance-preferences.ts b/packages/gateway-protocol/src/schema/ui-appearance-preferences.ts index c0ea844b051e..b6f1b3e25e72 100644 --- a/packages/gateway-protocol/src/schema/ui-appearance-preferences.ts +++ b/packages/gateway-protocol/src/schema/ui-appearance-preferences.ts @@ -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 || diff --git a/packages/gateway-protocol/src/schema/users-prefs.test.ts b/packages/gateway-protocol/src/schema/users-prefs.test.ts index c6c5e7e9ec2d..b252d43d6cfa 100644 --- a/packages/gateway-protocol/src/schema/users-prefs.test.ts +++ b/packages/gateway-protocol/src/schema/users-prefs.test.ts @@ -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", ); diff --git a/src/config/config-accent.test-support.ts b/src/config/config-accent.test-support.ts new file mode 100644 index 000000000000..472c3a1cc19c --- /dev/null +++ b/src/config/config-accent.test-support.ts @@ -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; diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index a3408f376291..0b91ae0585b5 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -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); }); }); diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index 9dffa6d25f98..d8d6859f1f62 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -4,7 +4,7 @@ export const AGENT_FIELD_HELP: Record = { "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.", diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 828c02ec78f0..ec6edaf78a60 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -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; diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index 6ee28057bee9..0da6adccb44a 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -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(), diff --git a/src/gateway/talk/handlers/config-accent.test-support.ts b/src/gateway/talk/handlers/config-accent.test-support.ts new file mode 100644 index 000000000000..94fdd5bce627 --- /dev/null +++ b/src/gateway/talk/handlers/config-accent.test-support.ts @@ -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", + }, +]; diff --git a/src/gateway/talk/handlers/index.test.ts b/src/gateway/talk/handlers/index.test.ts index 6699ef8553ff..ceed5cca02de 100644 --- a/src/gateway/talk/handlers/index.test.ts +++ b/src/gateway/talk/handlers/index.test.ts @@ -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 { diff --git a/src/gateway/talk/handlers/index.ts b/src/gateway/talk/handlers/index.ts index cfc68168b010..80472898e19d 100644 --- a/src/gateway/talk/handlers/index.ts +++ b/src/gateway/talk/handlers/index.ts @@ -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 }; } diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index a87023f65654..c482f63cabef 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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, diff --git a/ui/src/app/control-ui-presentation.test.ts b/ui/src/app/control-ui-presentation.test.ts index 1ae4bd244462..fe62ea789cdb 100644 --- a/ui/src/app/control-ui-presentation.test.ts +++ b/ui/src/app/control-ui-presentation.test.ts @@ -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"); diff --git a/ui/src/app/control-ui-presentation.ts b/ui/src/app/control-ui-presentation.ts index 372a19a75dac..c960acec1c84 100644 --- a/ui/src/app/control-ui-presentation.ts +++ b/ui/src/app/control-ui-presentation.ts @@ -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) { diff --git a/ui/src/app/server-prefs-intent.ts b/ui/src/app/server-prefs-intent.ts new file mode 100644 index 000000000000..1fedbe22865d --- /dev/null +++ b/ui/src/app/server-prefs-intent.ts @@ -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(); +const requestedDeviceLocalPrefResets = new Set(); +const requestedUiPrefWrites = new Set(); + +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)[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, "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", + }); +} diff --git a/ui/src/app/server-prefs.profile.test.ts b/ui/src/app/server-prefs.profile.test.ts index 883d3d5622d1..9ee3cd12b855 100644 --- a/ui/src/app/server-prefs.profile.test.ts +++ b/ui/src/app/server-prefs.profile.test.ts @@ -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 = { + "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 }; + 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 }).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 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 }), diff --git a/ui/src/app/server-prefs.read-only.test.ts b/ui/src/app/server-prefs.read-only.test.ts index 53b1c96deeaf..954d1bb8f2f1 100644 --- a/ui/src/app/server-prefs.read-only.test.ts +++ b/ui/src/app/server-prefs.read-only.test.ts @@ -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, diff --git a/ui/src/app/server-prefs.test.ts b/ui/src/app/server-prefs.test.ts index 84dd2c851bad..5e9563d20239 100644 --- a/ui/src/app/server-prefs.test.ts +++ b/ui/src/app/server-prefs.test.ts @@ -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, + }); }); }); diff --git a/ui/src/app/server-prefs.ts b/ui/src/app/server-prefs.ts index cae8dcaadbb9..47f18bdbbdbe 100644 --- a/ui/src/app/server-prefs.ts +++ b/ui/src/app/server-prefs.ts @@ -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( ? { ...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)[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)[key] = null; - } - continue; - } - (prefs as Record)[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(); -const requestedDeviceLocalPrefResets = new Set(); 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( @@ -291,6 +265,10 @@ export function resetServerUiPref( profileId?: string | null, ): UiSettings { const specification = SYNCED_PREFS[key]; + const applyReset = (patch: Partial) => + 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( 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( | ((value: SyncedPrefValue | undefined) => Partial) | 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, diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 978b4325c086..7d35776e791d 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -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 { diff --git a/ui/src/e2e/appearance-prefs.test-support.ts b/ui/src/e2e/appearance-prefs.test-support.ts new file mode 100644 index 000000000000..037bb97b73f6 --- /dev/null +++ b/ui/src/e2e/appearance-prefs.test-support.ts @@ -0,0 +1,67 @@ +import { expect } from "vitest"; +import type { MockGatewayControls, MockGatewayRequest } from "../test-helpers/control-ui-e2e.ts"; + +export function configResponse(prefs: Record, 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 { + 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; +} + +export function patchPrefs(request: MockGatewayRequest): Record { + 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 { + await expect + .poll(async () => (await gateway.getRequests(method)).length, { timeout: 10_000 }) + .toBe(count); +} + +export async function resetSyncedPreference(options: { + click: () => Promise; + expectedKey: string; + expectedPrefs?: Record; + gateway: MockGatewayControls; + hash: string; + remainingPrefs: Record; +}): Promise { + 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); +} diff --git a/ui/src/e2e/appearance-settings-defaults.e2e.test.ts b/ui/src/e2e/appearance-settings-defaults.e2e.test.ts index 144015e8584f..d41e1b855e36 100644 --- a/ui/src/e2e/appearance-settings-defaults.e2e.test.ts +++ b/ui/src/e2e/appearance-settings-defaults.e2e.test.ts @@ -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, 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 { - expect(value, label).toBeTruthy(); - expect(typeof value, label).toBe("object"); - expect(Array.isArray(value), label).toBe(false); - return value as Record; -} - -function patchPrefs(request: MockGatewayRequest): Record { - 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 { ); } -async function waitForRequestCount( - gateway: MockGatewayControls, - method: string, - count: number, -): Promise { - await expect - .poll(async () => (await gateway.getRequests(method)).length, { timeout: 10_000 }) - .toBe(count); -} - -async function resetSyncedPreference(options: { - click: () => Promise; - expectedKey: string; - gateway: MockGatewayControls; - hash: string; - remainingPrefs: Record; -}): Promise { - 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> { return page.evaluate((key) => { const raw = localStorage.getItem(key); @@ -311,7 +255,7 @@ suite.define(() => { remainingPrefs: withoutLocale, }); - const withoutTheme = { ...withoutLocale }; + const withoutTheme: Record = { ...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(); diff --git a/ui/src/e2e/settings-prefs-reconnect.e2e.test.ts b/ui/src/e2e/settings-prefs-reconnect.e2e.test.ts index e549924c3a2a..93cbe0f4ad3b 100644 --- a/ui/src/e2e/settings-prefs-reconnect.e2e.test.ts +++ b/ui/src/e2e/settings-prefs-reconnect.e2e.test.ts @@ -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); diff --git a/ui/src/e2e/theme-muted-contrast.e2e.test.ts b/ui/src/e2e/theme-muted-contrast.e2e.test.ts index 1d544954a2fb..2fa552ff0a29 100644 --- a/ui/src/e2e/theme-muted-contrast.e2e.test.ts +++ b/ui/src/e2e/theme-muted-contrast.e2e.test.ts @@ -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" }); diff --git a/ui/src/e2e/theme-selection-defaults.e2e.test.ts b/ui/src/e2e/theme-selection-defaults.e2e.test.ts new file mode 100644 index 000000000000..3643f898480f --- /dev/null +++ b/ui/src/e2e/theme-selection-defaults.e2e.test.ts @@ -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, 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 }); + }, + ); + }); +}); diff --git a/ui/src/i18n/locales/en-settings.ts b/ui/src/i18n/locales/en-settings.ts index 7d962a4dd673..bd30e61983ec 100644 --- a/ui/src/i18n/locales/en-settings.ts +++ b/ui/src/i18n/locales/en-settings.ts @@ -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", diff --git a/ui/src/pages/config/config-page.test.ts b/ui/src/pages/config/config-page.test.ts index 034cf33f82dc..7f39c24962cb 100644 --- a/ui/src/pages/config/config-page.test.ts +++ b/ui/src/pages/config/config-page.test.ts @@ -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([ diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index 7b0841e79792..9d7955cf13c3 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -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) { - this.settings = patchSettings(patch); + private applySettings(patch: Partial, 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"), }); } diff --git a/ui/src/pages/config/view-appearance.ts b/ui/src/pages/config/view-appearance.ts index 4c3eb8bf69fc..3f11ccfd136f 100644 --- a/ui/src/pages/config/view-appearance.ts +++ b/ui/src/pages/config/view-appearance.ts @@ -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`
${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)} />