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
This commit is contained in:
Peter Steinberger
2026-08-12 00:46:35 -07:00
committed by GitHub
parent 94c28e093d
commit bfe1f33ea0
13 changed files with 180 additions and 28 deletions
+38
View File
@@ -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);
},
};
}
+3
View File
@@ -8,6 +8,9 @@
/>
<title>OpenClaw Control</title>
<meta name="color-scheme" content="dark light" />
<!-- These mirror the claw theme's --bg tokens for first paint; runtime theme application keeps them synchronized. -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf9f7" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0e1015" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+28
View File
@@ -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<void>().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);
}
});
});
+7
View File
@@ -76,6 +76,13 @@ function applyThemePresentation(settings: ReturnType<typeof loadSettings>): 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<HTMLMetaElement>('meta[name="theme-color"]')) {
meta.content = background;
meta.removeAttribute("media");
}
}
}
function createApplicationTheme(
@@ -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);
});
});
@@ -485,6 +485,7 @@ describeBrowserLayout("app chrome interaction styles", () => {
<span class="nav-item">Mobile navigation</span>
<div class="file-view__search"><input value="query" /></div>
<div class="sidebar-agent-menu__filter"><input value="agent" /></div>
<input class="settings-sidebar__search-input" value="settings" />
<div class="sidebar-recent-session sidebar-recent-session--child">
<span class="sidebar-recent-session__name">Child session</span>
<span class="session-row-trail">3m</span>
@@ -509,6 +510,7 @@ describeBrowserLayout("app chrome interaction styles", () => {
coarsePointer: matchMedia("(hover: none) and (pointer: coarse)").matches,
agentFilter: fontSize(".sidebar-agent-menu__filter input"),
fileSearch: fontSize(".file-view__search input"),
settingsSearch: fontSize(".settings-sidebar__search-input"),
navItem: fontSize(".shell--mobile-nav .nav-item"),
};
});
@@ -520,6 +522,7 @@ describeBrowserLayout("app chrome interaction styles", () => {
coarsePointer: true,
agentFilter: 16,
fileSearch: 16,
settingsSearch: 16,
navItem: 12,
});
@@ -531,6 +534,7 @@ describeBrowserLayout("app chrome interaction styles", () => {
expect(scaled.childName).toBeCloseTo(12 * 1.4, 1);
expect(scaled.childTrail).toBeCloseTo(10 * 1.4, 1);
expect(scaled.fileSearch).toBeCloseTo(12 * 1.4, 1);
expect(scaled.settingsSearch).toBeCloseTo(12.5 * 1.4, 1);
expect(scaled.navItem).toBeCloseTo(12 * 1.4, 1);
} finally {
await page.close().catch(() => {});
+26
View File
@@ -638,6 +638,9 @@ body {
that `hidden` would let strand the shell off-screen. */
overflow: clip;
overscroll-behavior: none;
/* The app supplies its own pressed and selected states; WebKit's tap flash
reads as foreign chrome on top of them. */
-webkit-tap-highlight-color: transparent;
/* iOS WKWebView font boosting inflates rendered text without re-running
layout, so labels spill out of fixed-size chrome (welcome chips, badges).
Locking text-size-adjust keeps rendered metrics equal to layout metrics. */
@@ -645,6 +648,17 @@ body {
text-size-adjust: 100%;
}
a,
button,
label,
summary,
input,
select,
textarea,
[role="button"] {
touch-action: manipulation;
}
body {
margin: 0;
font: 400 14px/1.55 var(--font-body);
@@ -659,6 +673,18 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* iOS Safari zooms the viewport when a focused text control is under 16px;
important keeps feature-local typography from silently dropping this floor.
Controls that manage their own touch size declare it via
--control-ui-touch-input-size; the floor never caps above 16px. */
@media (pointer: coarse) {
input,
select,
textarea {
font-size: max(16px, var(--control-ui-touch-input-size, 1em)) !important;
}
}
@media (min-width: 1600px) {
body {
font-size: 15px;
+2 -6
View File
@@ -2450,6 +2450,8 @@ button.chat-reply-preview--message:disabled {
}
.agent-chat__composer-combobox > :is(textarea, input) {
--control-ui-touch-input-size: var(--control-ui-input-text-size);
width: 100%;
min-height: var(--chat-composer-control-height);
max-height: calc(7em + 24px);
@@ -2484,12 +2486,6 @@ button.chat-reply-preview--message:disabled {
text-overflow: ellipsis;
}
@media (hover: none) and (pointer: coarse) {
.agent-chat__composer-combobox > :is(textarea, input) {
font-size: var(--control-ui-input-text-size);
}
}
.agent-chat__composer-status-stack {
display: flex;
flex-wrap: wrap;
+2 -6
View File
@@ -1507,6 +1507,8 @@ openclaw-session-discussion {
}
.file-view__search input {
--control-ui-touch-input-size: var(--control-ui-text-sm);
min-width: 0;
flex: 1;
height: 28px;
@@ -1520,12 +1522,6 @@ openclaw-session-discussion {
font-size: var(--control-ui-text-sm);
}
@media (hover: none) and (pointer: coarse) {
.file-view__search input {
font-size: max(16px, var(--control-ui-text-sm));
}
}
.file-view__search input:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
-4
View File
@@ -1106,7 +1106,6 @@ openclaw-session-owner-chip {
background: transparent;
color: var(--muted);
cursor: var(--cursor-action);
touch-action: manipulation;
}
.oc-sensitive-toggle:hover:not(:disabled) {
@@ -4408,7 +4407,6 @@ td.data-table-key-col {
background-color var(--duration-fast) var(--ease-in-out),
border-color var(--duration-fast) var(--ease-in-out),
color var(--duration-fast) var(--ease-in-out);
touch-action: manipulation;
}
.agent-tools-runtime-chip:hover {
@@ -4465,7 +4463,6 @@ td.data-table-key-col {
transition:
background-color var(--duration-fast) var(--ease-in-out),
color var(--duration-fast) var(--ease-in-out);
touch-action: manipulation;
}
.agent-tools-group__summary::before {
@@ -4582,7 +4579,6 @@ td.data-table-key-col {
transition:
background-color var(--duration-fast) var(--ease-in-out),
color var(--duration-fast) var(--ease-in-out);
touch-action: manipulation;
}
.agent-tool-summary::after {
+4 -12
View File
@@ -391,6 +391,8 @@ html.openclaw-native-web-chrome .shell-chrome-controls {
}
.settings-sidebar__search-input {
--control-ui-touch-input-size: calc(12.5px * var(--control-ui-text-scale));
width: 100%;
min-width: 0;
height: 34px;
@@ -423,12 +425,6 @@ html.openclaw-native-web-chrome .shell-chrome-controls {
appearance: none;
}
@media (hover: none) and (pointer: coarse) {
.settings-sidebar__search-input {
font-size: max(16px, calc(12.5px * var(--control-ui-text-scale)));
}
}
.settings-sidebar__search-clear {
position: absolute;
inset-inline-end: 5px;
@@ -3495,6 +3491,8 @@ wa-dropdown.sidebar-identity-menu::part(menu) {
}
.sidebar-agent-menu__filter input {
--control-ui-touch-input-size: var(--control-ui-text-sm);
width: 100%;
padding: 5px 8px;
border-radius: 6px;
@@ -3505,12 +3503,6 @@ wa-dropdown.sidebar-identity-menu::part(menu) {
font-size: var(--control-ui-text-sm);
}
@media (hover: none) and (pointer: coarse) {
.sidebar-agent-menu__filter input {
font-size: max(16px, var(--control-ui-text-sm));
}
}
.sidebar-agent-menu__empty {
padding: 6px 8px;
font-size: var(--control-ui-text-sm);
+2
View File
@@ -560,6 +560,8 @@ wa-radio-group.settings-segmented::part(radios) {
.settings-input,
.settings-select {
--control-ui-touch-input-size: var(--control-ui-input-text-size);
width: 100%;
min-width: 0;
font: inherit;
+6
View File
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib";
import type { Plugin, UserConfig } from "vite";
import { controlUiCodeSplitting } from "./config/control-ui-chunking.ts";
import { controlUiHoverGuardPlugin } from "./config/control-ui-hover-guard.ts";
import { controlUiLocaleModulesPlugin } from "./config/control-ui-locales.ts";
import { normalizeControlUiBuildInfo } from "./src/build-info-normalizers.ts";
import type { ControlUiBuildInfo } from "./src/build-info.ts";
@@ -431,6 +432,11 @@ export default function controlUiViteConfig(options: { outDir?: string } = {}):
"globalThis.OPENCLAW_CONTROL_UI_BUILD_INFO": JSON.stringify(buildInfo),
},
publicDir: path.resolve(here, "public"),
css: {
postcss: {
plugins: [controlUiHoverGuardPlugin()],
},
},
optimizeDeps: {
include: [
"ipaddr.js",