diff --git a/ui/config/control-ui-hover-guard.ts b/ui/config/control-ui-hover-guard.ts
new file mode 100644
index 000000000000..6f03bb826afb
--- /dev/null
+++ b/ui/config/control-ui-hover-guard.ts
@@ -0,0 +1,38 @@
+import type { AnyNode, Plugin, Rule } from "postcss";
+
+function isHoverGuarded(rule: Rule): boolean {
+ let ancestor: AnyNode | undefined = rule.parent;
+ while (ancestor) {
+ if (ancestor.type === "atrule" && ancestor.params.includes("hover:")) {
+ return true;
+ }
+ ancestor = ancestor.parent;
+ }
+ return false;
+}
+
+export function controlUiHoverGuardPlugin(): Plugin {
+ return {
+ postcssPlugin: "control-ui-hover-guard",
+ Rule(rule, { AtRule }) {
+ if (!rule.selector.includes(":hover") || isHoverGuarded(rule)) {
+ return;
+ }
+
+ const hoverSelectors = rule.selectors.filter((selector) => selector.includes(":hover"));
+ const otherSelectors = rule.selectors.filter((selector) => !selector.includes(":hover"));
+ const hoverRule = rule.clone();
+ hoverRule.selectors = hoverSelectors;
+ const guard = new AtRule({ name: "media", params: "(hover: hover)" });
+ guard.append(hoverRule);
+
+ if (otherSelectors.length === 0) {
+ rule.replaceWith(guard);
+ return;
+ }
+
+ rule.selectors = otherSelectors;
+ rule.after(guard);
+ },
+ };
+}
diff --git a/ui/index.html b/ui/index.html
index fd112516b95e..131fc1622245 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -8,6 +8,9 @@
/>
OpenClaw Control
+
+
+
diff --git a/ui/src/app/bootstrap.test.ts b/ui/src/app/bootstrap.test.ts
index 173c9d16f768..a137178fc5c4 100644
--- a/ui/src/app/bootstrap.test.ts
+++ b/ui/src/app/bootstrap.test.ts
@@ -778,4 +778,32 @@ describe("normalizeInitialApplicationLocation", () => {
window.history.replaceState({}, "", previousUrl);
}
});
+
+ it("synchronizes every theme-color meta with the resolved theme background", () => {
+ const previousSettings = loadSettings();
+ const style = document.createElement("style");
+ style.textContent = ':root[data-theme="light"] { --bg: #123456; }';
+ const lightMeta = document.createElement("meta");
+ lightMeta.name = "theme-color";
+ lightMeta.media = "(prefers-color-scheme: light)";
+ const darkMeta = document.createElement("meta");
+ darkMeta.name = "theme-color";
+ darkMeta.media = "(prefers-color-scheme: dark)";
+ document.head.append(style, lightMeta, darkMeta);
+ saveSettings({ ...previousSettings, theme: "claw", themeMode: "light" });
+ const runtime = bootstrapApplication({ sessionPathBuilderReady: deferred().promise });
+
+ try {
+ expect(lightMeta.content).toBe("#123456");
+ expect(darkMeta.content).toBe("#123456");
+ expect(lightMeta.hasAttribute("media")).toBe(false);
+ expect(darkMeta.hasAttribute("media")).toBe(false);
+ } finally {
+ runtime.stop();
+ style.remove();
+ lightMeta.remove();
+ darkMeta.remove();
+ saveSettings(previousSettings);
+ }
+ });
});
diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts
index 82426d02697f..678a6cff2180 100644
--- a/ui/src/app/bootstrap.ts
+++ b/ui/src/app/bootstrap.ts
@@ -76,6 +76,13 @@ function applyThemePresentation(settings: ReturnType): void
root.style.colorScheme = root.dataset.themeMode;
root.style.setProperty("--control-ui-text-scale", `${(settings.textScale ?? 100) / 100}`);
syncCustomThemeStyleTag(settings.customTheme);
+ const background = getComputedStyle(root).getPropertyValue("--bg").trim();
+ if (background) {
+ for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
+ meta.content = background;
+ meta.removeAttribute("media");
+ }
+ }
}
function createApplicationTheme(
diff --git a/ui/src/app/control-ui-hover-guard.node.test.ts b/ui/src/app/control-ui-hover-guard.node.test.ts
new file mode 100644
index 000000000000..ac5d988be24a
--- /dev/null
+++ b/ui/src/app/control-ui-hover-guard.node.test.ts
@@ -0,0 +1,58 @@
+// @vitest-environment node
+import postcss, { type AtRule, type Rule } from "postcss";
+import { describe, expect, it } from "vitest";
+import { controlUiHoverGuardPlugin } from "../../config/control-ui-hover-guard.ts";
+
+async function transform(css: string) {
+ return postcss([controlUiHoverGuardPlugin()]).process(css, { from: undefined });
+}
+
+function requireRule(node: unknown): Rule {
+ expect(node).toMatchObject({ type: "rule" });
+ return node as Rule;
+}
+
+function requireAtRule(node: unknown): AtRule {
+ expect(node).toMatchObject({ type: "atrule" });
+ return node as AtRule;
+}
+
+describe("Control UI hover guard", () => {
+ it("wraps a hover rule in a hover-capable media query", async () => {
+ const result = await transform(".button:hover { color: red; }");
+ const guard = requireAtRule(result.root.first);
+
+ expect(guard.params).toBe("(hover: hover)");
+ expect(requireRule(guard.first).selector).toBe(".button:hover");
+ });
+
+ it("splits mixed selector lists without moving non-hover selectors", async () => {
+ const result = await transform(".a:hover, .b:focus { color: red; }");
+ const [original, guard] = result.root.nodes;
+
+ expect(requireRule(original).selector).toBe(".b:focus");
+ expect(requireRule(requireAtRule(guard).first).selector).toBe(".a:hover");
+ });
+
+ it("does not double-wrap an already guarded hover rule", async () => {
+ const css = "@media (hover: hover) { .a:hover { color: red; } }";
+
+ expect((await transform(css)).css).toBe(css);
+ });
+
+ it("preserves an outer media condition around the hover guard", async () => {
+ const result = await transform("@media (max-width: 768px) { .a:hover { color: red; } }");
+ const outer = requireAtRule(result.root.first);
+ const guard = requireAtRule(outer.first);
+
+ expect(outer.params).toBe("(max-width: 768px)");
+ expect(guard.params).toBe("(hover: hover)");
+ expect(requireRule(guard.first).selector).toBe(".a:hover");
+ });
+
+ it("passes CSS without hover selectors through byte-identically", async () => {
+ const css = ".button:focus { color: red; }\n";
+
+ expect((await transform(css)).css).toBe(css);
+ });
+});
diff --git a/ui/src/components/form-controls.browser.test.ts b/ui/src/components/form-controls.browser.test.ts
index 4a567d358113..8fdc2658c3f3 100644
--- a/ui/src/components/form-controls.browser.test.ts
+++ b/ui/src/components/form-controls.browser.test.ts
@@ -485,6 +485,7 @@ describeBrowserLayout("app chrome interaction styles", () => {
Mobile navigation
+