fix(ui): make scrollbars contextual (#107349)

This commit is contained in:
Peter Steinberger
2026-07-14 03:06:28 -07:00
committed by GitHub
parent ee472357cb
commit e052d122d6
5 changed files with 264 additions and 11 deletions

View File

@@ -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(() => {});
}
});
});

View 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();
}
});
});

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {