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 +