From e052d122d6ac84e697db243159cb15b28a18a4fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 03:06:28 -0700 Subject: [PATCH] fix(ui): make scrollbars contextual (#107349) --- .../components/form-controls.browser.test.ts | 74 ++++++++ ui/src/e2e/app-chrome-interaction.e2e.test.ts | 172 ++++++++++++++++++ ui/src/styles/base.css | 17 +- ui/src/styles/chat/layout.css | 2 + ui/src/styles/layout.css | 10 +- 5 files changed, 264 insertions(+), 11 deletions(-) create mode 100644 ui/src/e2e/app-chrome-interaction.e2e.test.ts diff --git a/ui/src/components/form-controls.browser.test.ts b/ui/src/components/form-controls.browser.test.ts index bba0fb5055d5..379b40c47449 100644 --- a/ui/src/components/form-controls.browser.test.ts +++ b/ui/src/components/form-controls.browser.test.ts @@ -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(` + + + + + + +
+
App chrome tile
+
+
+
Selectable transcript
+ + + `); + + 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(() => {}); + } + }); +}); diff --git a/ui/src/e2e/app-chrome-interaction.e2e.test.ts b/ui/src/e2e/app-chrome-interaction.e2e.test.ts new file mode 100644 index 000000000000..ead871796696 --- /dev/null +++ b/ui/src/e2e/app-chrome-interaction.e2e.test.ts @@ -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 { + 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(".sidebar"); + const sessions = document.querySelector(".sidebar-recent-sessions"); + const thread = document.querySelector(".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(".content"); + const nav = document.querySelector(".settings-sidebar__nav"); + const search = document.querySelector(".settings-sidebar__search-input"); + const sidebar = document.querySelector(".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(); + } + }); +}); diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 700886279d96..77e002020828 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -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 { diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 5d6600a4dea0..8c9b432a477e 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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 diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index d0226b3ad372..1c4085c576ce 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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 {