From bfe1f33ea0ec3ccc5bb8738eb1dfc2b6f8dc0524 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 00:46:35 -0700 Subject: [PATCH] improve(ui): make Control UI feel native on mobile (#122492) * improve(ui): make Control UI feel native on mobile * fix(ui): keep coarse-pointer input floor text-scale aware * fix(ui): let self-sized controls opt out of the touch input floor * fix(ui): fold per-control coarse-pointer font floors into the shared touch floor --- ui/config/control-ui-hover-guard.ts | 38 ++++++++++++ ui/index.html | 3 + ui/src/app/bootstrap.test.ts | 28 +++++++++ ui/src/app/bootstrap.ts | 7 +++ .../app/control-ui-hover-guard.node.test.ts | 58 +++++++++++++++++++ .../components/form-controls.browser.test.ts | 4 ++ ui/src/styles/base.css | 26 +++++++++ ui/src/styles/chat/layout.css | 8 +-- ui/src/styles/chat/sidebar.css | 8 +-- ui/src/styles/components.css | 4 -- ui/src/styles/layout.css | 16 ++--- ui/src/styles/settings.css | 2 + ui/vite.config.ts | 6 ++ 13 files changed, 180 insertions(+), 28 deletions(-) create mode 100644 ui/config/control-ui-hover-guard.ts create mode 100644 ui/src/app/control-ui-hover-guard.node.test.ts 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 +