From f25385fcda418e53c16f344604dc8cc5cfae1f50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 13:45:18 -0700 Subject: [PATCH] fix(ui): contain transcript focus ring (#120693) --- ui/src/e2e/app-chrome-interaction.e2e.test.ts | 49 ++++++++++++++++++- ui/src/styles/chat/layout.css | 6 +++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/ui/src/e2e/app-chrome-interaction.e2e.test.ts b/ui/src/e2e/app-chrome-interaction.e2e.test.ts index 4eb4c385c987..22072d11a85a 100644 --- a/ui/src/e2e/app-chrome-interaction.e2e.test.ts +++ b/ui/src/e2e/app-chrome-interaction.e2e.test.ts @@ -35,6 +35,25 @@ async function dragAcross(page: Page, locator: Locator): Promise { return page.evaluate(() => globalThis.getSelection()?.toString() ?? ""); } +async function readFocusOutline(locator: Locator) { + return locator.evaluate((element) => { + const styles = getComputedStyle(element); + const colorProbe = document.createElement("span"); + colorProbe.style.color = "var(--muted-strong)"; + document.body.append(colorProbe); + const mutedStrongColor = getComputedStyle(colorProbe).color; + colorProbe.remove(); + return { + focusVisible: element.matches(":focus-visible"), + mutedStrongColor, + outlineColor: styles.outlineColor, + outlineOffset: styles.outlineOffset, + outlineStyle: styles.outlineStyle, + outlineWidth: styles.outlineWidth, + }; + }); +} + async function captureUiProof(page: Page, fileName: string) { if (!captureUiProofEnabled) { return; @@ -94,8 +113,36 @@ suite.define(() => { sidebarScrollbar: "6px", }); expect(await dragAcross(page, transcript)).toContain("Selectable transcript"); + const thread = page.locator(".chat-thread"); + expect(await readFocusOutline(thread)).toMatchObject({ + focusVisible: false, + outlineStyle: "none", + }); await captureUiProof(page, "01-chat-selectable-transcript.png"); + await thread.focus(); + await page.keyboard.press("Tab"); + await expect + .poll(() => thread.evaluate((element) => element !== document.activeElement)) + .toBe(true); + await page.keyboard.press("Shift+Tab"); + await expect + .poll(() => + thread.evaluate( + (element) => element === document.activeElement && element.matches(":focus-visible"), + ), + ) + .toBe(true); + const focusedOutline = await readFocusOutline(thread); + expect(focusedOutline).toMatchObject({ + focusVisible: true, + outlineOffset: "-2px", + outlineStyle: "solid", + outlineWidth: "2px", + }); + expect(focusedOutline.outlineColor).toBe(focusedOutline.mutedStrongColor); + await captureUiProof(page, "02-chat-thread-keyboard-focus.png"); + await page.setViewportSize({ height: 650, width: 1440 }); // Appearance renders schema-independent theme/UI sections that overflow // 650px even against the mock gateway's tiny config fixture; General @@ -148,7 +195,7 @@ suite.define(() => { await content.evaluate((element) => { element.scrollTop = Math.min(160, element.scrollHeight - element.clientHeight); }); - await captureUiProof(page, "02-settings-contextual-scrollbars.png"); + await captureUiProof(page, "03-settings-contextual-scrollbars.png"); }, ); }); diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 56661303820d..98d249f89591 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -316,6 +316,12 @@ openclaw-chat-page { background: transparent; } +/* The oversized clipped transcript owns its focus ring, so it stays inward to keep every edge visible. */ +.chat-thread:focus-visible { + outline: 2px solid var(--muted-strong); + outline-offset: -2px; +} + :root .chat-thread::-webkit-scrollbar-thumb { background: var(--muted-strong); }