mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
94c28e093d
commit
bfe1f33ea0
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user