mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 07:01:18 +00:00
fix(ui): make scrollbars contextual (#107349)
This commit is contained in:
committed by
GitHub
parent
ee472357cb
commit
e052d122d6
@@ -220,3 +220,77 @@ describeBrowserLayout("mount fallback cursor", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describeBrowserLayout("app chrome interaction styles", () => {
|
||||
it("keeps sidebars compact while preserving normal content scroll and text entry", async () => {
|
||||
const browser = await chromium.launch({
|
||||
executablePath: chromiumExecutablePath,
|
||||
headless: true,
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
|
||||
await page.setContent(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><style>${readUiCss()}</style></head>
|
||||
<body>
|
||||
<aside class="settings-sidebar">
|
||||
<nav class="settings-sidebar__nav">
|
||||
<span class="settings-sidebar__item-label">Settings row</span>
|
||||
</nav>
|
||||
<input class="settings-sidebar__search-input" value="editable settings search" />
|
||||
</aside>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-recent-sessions">Recent session</div>
|
||||
</aside>
|
||||
<main class="content" style="height: 100px">
|
||||
<div class="settings-card">App chrome tile</div>
|
||||
<div style="height: 200px"></div>
|
||||
</main>
|
||||
<section class="chat-thread" style="height: 100px">Selectable transcript</section>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const style = (selector: string) => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
throw new Error(`Missing interaction fixture ${selector}`);
|
||||
}
|
||||
return getComputedStyle(node);
|
||||
};
|
||||
const scrollbarWidth = (selector: string) => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
throw new Error(`Missing scrollbar fixture ${selector}`);
|
||||
}
|
||||
return getComputedStyle(node, "::-webkit-scrollbar").width;
|
||||
};
|
||||
return {
|
||||
chatSelection: style(".chat-thread").userSelect,
|
||||
chromeSelection: style(".settings-card").userSelect,
|
||||
contentScrollbar: scrollbarWidth(".content"),
|
||||
inputSelection: style(".settings-sidebar__search-input").userSelect,
|
||||
regularSidebarScrollbar: scrollbarWidth(".sidebar-recent-sessions"),
|
||||
regularSidebarSelection: style(".sidebar-recent-sessions").userSelect,
|
||||
settingsSidebarScrollbar: scrollbarWidth(".settings-sidebar__nav"),
|
||||
settingsSidebarSelection: style(".settings-sidebar__nav").userSelect,
|
||||
};
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
chatSelection: "text",
|
||||
chromeSelection: "none",
|
||||
contentScrollbar: "12px",
|
||||
inputSelection: "text",
|
||||
regularSidebarScrollbar: "6px",
|
||||
regularSidebarSelection: "none",
|
||||
settingsSidebarScrollbar: "6px",
|
||||
settingsSidebarSelection: "none",
|
||||
});
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
172
ui/src/e2e/app-chrome-interaction.e2e.test.ts
Normal file
172
ui/src/e2e/app-chrome-interaction.e2e.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// Control UI tests cover contextual scrollbars and native-style text selection.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { chromium, type Browser, type Locator, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
resolvePlaywrightChromiumExecutablePath,
|
||||
startControlUiE2eServer,
|
||||
type ControlUiE2eServer,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
||||
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
|
||||
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
|
||||
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
|
||||
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const uiProofArtifactDir = path.join(
|
||||
process.cwd(),
|
||||
".artifacts",
|
||||
"control-ui-e2e",
|
||||
"app-chrome-interaction",
|
||||
);
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
|
||||
async function dragAcross(page: Page, locator: Locator): Promise<string> {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected a visible text-selection target");
|
||||
}
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(box.x + 2, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + Math.max(3, box.width - 2), y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
return page.evaluate(() => globalThis.getSelection()?.toString() ?? "");
|
||||
}
|
||||
|
||||
async function captureUiProof(page: Page, fileName: string) {
|
||||
if (!captureUiProofEnabled) {
|
||||
return;
|
||||
}
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, fileName),
|
||||
});
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI app chrome interaction mocked Gateway E2E", () => {
|
||||
beforeAll(async () => {
|
||||
if (!chromiumAvailable) {
|
||||
throw new Error(
|
||||
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
|
||||
);
|
||||
}
|
||||
server = await startControlUiE2eServer();
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("uses compact sidebar scrollbars and keeps selection in chat and inputs", async () => {
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
}
|
||||
const context = await browser.newContext({
|
||||
locale: "en-US",
|
||||
recordVideo: captureUiProofEnabled
|
||||
? { dir: path.join(uiProofArtifactDir, "video"), size: { height: 900, width: 1440 } }
|
||||
: undefined,
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1440 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
historyMessages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Selectable transcript content" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const transcript = page.getByText("Selectable transcript content", { exact: true });
|
||||
await transcript.waitFor();
|
||||
const chatStyles = await page.evaluate(() => {
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
const sessions = document.querySelector<HTMLElement>(".sidebar-recent-sessions");
|
||||
const thread = document.querySelector<HTMLElement>(".chat-thread");
|
||||
if (!sidebar || !sessions || !thread) {
|
||||
throw new Error("Missing chat interaction surface");
|
||||
}
|
||||
return {
|
||||
chatSelection: getComputedStyle(thread).userSelect,
|
||||
chatScrollbar: getComputedStyle(thread, "::-webkit-scrollbar").width,
|
||||
sidebarSelection: getComputedStyle(sidebar).userSelect,
|
||||
sidebarScrollbar: getComputedStyle(sessions, "::-webkit-scrollbar").width,
|
||||
};
|
||||
});
|
||||
expect(chatStyles).toEqual({
|
||||
chatSelection: "text",
|
||||
chatScrollbar: "12px",
|
||||
sidebarSelection: "none",
|
||||
sidebarScrollbar: "6px",
|
||||
});
|
||||
expect(await dragAcross(page, transcript)).toContain("Selectable transcript");
|
||||
await captureUiProof(page, "01-chat-selectable-transcript.png");
|
||||
|
||||
await page.goto(`${server.baseUrl}settings/general`);
|
||||
const settingsSidebar = page.locator(".settings-sidebar");
|
||||
const settingsTitle = settingsSidebar.locator(".settings-sidebar__title");
|
||||
const settingsSearch = settingsSidebar.locator(".settings-sidebar__search-input");
|
||||
const content = page.locator(".content");
|
||||
await settingsSidebar.waitFor();
|
||||
await expect
|
||||
.poll(() => content.evaluate((element) => element.scrollHeight))
|
||||
.toBeGreaterThan(await content.evaluate((element) => element.clientHeight));
|
||||
|
||||
const settingsStyles = await page.evaluate(() => {
|
||||
const contentNode = document.querySelector<HTMLElement>(".content");
|
||||
const nav = document.querySelector<HTMLElement>(".settings-sidebar__nav");
|
||||
const search = document.querySelector<HTMLElement>(".settings-sidebar__search-input");
|
||||
const sidebar = document.querySelector<HTMLElement>(".settings-sidebar");
|
||||
if (!contentNode || !nav || !search || !sidebar) {
|
||||
throw new Error("Missing settings interaction surface");
|
||||
}
|
||||
return {
|
||||
contentScrollbar: getComputedStyle(contentNode, "::-webkit-scrollbar").width,
|
||||
contentSelection: getComputedStyle(contentNode).userSelect,
|
||||
inputSelection: getComputedStyle(search).userSelect,
|
||||
sidebarScrollbar: getComputedStyle(nav, "::-webkit-scrollbar").width,
|
||||
sidebarSelection: getComputedStyle(sidebar).userSelect,
|
||||
};
|
||||
});
|
||||
expect(settingsStyles).toEqual({
|
||||
contentScrollbar: "12px",
|
||||
contentSelection: "none",
|
||||
inputSelection: "text",
|
||||
sidebarScrollbar: "6px",
|
||||
sidebarSelection: "none",
|
||||
});
|
||||
|
||||
await page.evaluate(() => globalThis.getSelection()?.removeAllRanges());
|
||||
expect(await dragAcross(page, settingsTitle)).toBe("");
|
||||
await settingsSearch.selectText();
|
||||
expect(
|
||||
await settingsSearch.evaluate(
|
||||
(element) =>
|
||||
element instanceof HTMLInputElement &&
|
||||
element.selectionStart === 0 &&
|
||||
element.selectionEnd === element.value.length,
|
||||
),
|
||||
).toBe(true);
|
||||
await content.evaluate((element) => {
|
||||
element.scrollTop = Math.min(160, element.scrollHeight - element.clientHeight);
|
||||
});
|
||||
await captureUiProof(page, "02-settings-contextual-scrollbars.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -529,6 +529,10 @@ body {
|
||||
letter-spacing: -0.01em;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
/* App chrome behaves like native controls: dragging across labels, tiles,
|
||||
and navigation must not create accidental text selections. Text-entry
|
||||
surfaces and document-like content opt back in below or in route CSS. */
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -619,15 +623,22 @@ select {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable]:not([contenteditable="false"]) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection-bg);
|
||||
color: var(--selection-fg);
|
||||
}
|
||||
|
||||
/* Scrollbar styling - Minimal, barely visible */
|
||||
/* Main/document surfaces keep a normal scrollbar hit target. Dense sidebars
|
||||
override this inherited size locally so their row actions retain room. */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: var(--scrollbar-size, 12px);
|
||||
height: var(--scrollbar-size, 12px);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
|
||||
@@ -109,6 +109,8 @@ openclaw-chat-page {
|
||||
/* Grow, shrink, and use 0 base for proper scrolling */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* The transcript is document content, unlike the surrounding app chrome. */
|
||||
user-select: text;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-color: var(--muted-strong) transparent;
|
||||
/* The tall top inset is a titlebar band: it keeps the floating rail
|
||||
|
||||
@@ -238,6 +238,7 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse {
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
--scrollbar-size: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
@@ -795,6 +796,7 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse {
|
||||
/* Sticky headers inside the sidebar reuse --sidebar-bg so scrolled rows
|
||||
never shine through with a mismatched tone. */
|
||||
--sidebar-bg: color-mix(in srgb, var(--bg) 96%, var(--bg-elevated) 4%);
|
||||
--scrollbar-size: 6px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -803,14 +805,6 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--sidebar-bg);
|
||||
/* Sidebar chrome is controls, not copy; drag-selecting row labels reads as
|
||||
broken. Editable fields inside (menu filters) re-enable selection below. */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar input,
|
||||
.sidebar textarea {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
:root[data-theme-mode="light"] .sidebar {
|
||||
|
||||
Reference in New Issue
Block a user