diff --git a/ui/src/e2e/chat-composer-focus.e2e.test.ts b/ui/src/e2e/chat-composer-focus.e2e.test.ts new file mode 100644 index 000000000000..4d4ba72262e6 --- /dev/null +++ b/ui/src/e2e/chat-composer-focus.e2e.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from "vitest"; +import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ + name: "Control UI chat composer focus", +}); + +suite.define(() => { + it("uses a visible neutral focus treatment in dark and light themes", async () => { + await suite.withPage({ viewport: { width: 1440, height: 900 } }, async ({ page }) => { + const gateway = await installMockGateway(page); + await page.goto(`${suite.server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + + const composer = page.locator(".agent-chat__input"); + const textarea = composer.locator("textarea"); + await composer.waitFor({ state: "visible" }); + + for (const theme of ["dark", "light"] as const) { + await page.evaluate((nextTheme) => { + document.documentElement.dataset.theme = "claw"; + document.documentElement.dataset.themeMode = nextTheme; + }, theme); + await textarea.evaluate((element) => element.blur()); + await page.waitForTimeout(150); + const unfocused = await composer.evaluate((element) => { + const style = getComputedStyle(element); + return { borderColor: style.borderColor, boxShadow: style.boxShadow }; + }); + + await textarea.focus(); + await page.waitForTimeout(150); + const focused = await composer.evaluate((element) => { + const style = getComputedStyle(element); + const parseRgb = (color: string): [number, number, number] => { + const values = color + .match(/[\d.]+/g) + ?.slice(0, 3) + .map(Number); + if (!values || values.length !== 3) { + throw new Error(`expected an RGB color, received ${color}`); + } + return ( + color.startsWith("color(srgb") ? values.map((value) => value * 255) : values + ) as [number, number, number]; + }; + const luminance = (color: string): number => { + const linearize = (channel: number) => { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }; + const channels = parseRgb(color); + return ( + 0.2126 * linearize(channels[0]) + + 0.7152 * linearize(channels[1]) + + 0.0722 * linearize(channels[2]) + ); + }; + const borderLuminance = luminance(style.borderColor); + const surfaceLuminance = luminance(style.backgroundColor); + const borderChannels = parseRgb(style.borderColor); + const contrast = + (Math.max(borderLuminance, surfaceLuminance) + 0.05) / + (Math.min(borderLuminance, surfaceLuminance) + 0.05); + + return { + borderColor: style.borderColor, + boxShadow: style.boxShadow, + contrast, + neutralChannelSpread: Math.max(...borderChannels) - Math.min(...borderChannels), + }; + }); + + expect(focused.borderColor).not.toBe(unfocused.borderColor); + expect(focused.boxShadow).not.toBe(unfocused.boxShadow); + expect(focused.neutralChannelSpread).toBeLessThanOrEqual(24); + expect(focused.contrast).toBeGreaterThanOrEqual(3); + } + }); + }); +}); diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 071c0446bff8..ceab28ce9e20 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -2358,8 +2358,12 @@ openclaw-chat-video-player { } .agent-chat__input:focus-within { - border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); - box-shadow: 0 0 0 3px var(--accent-subtle); + /* Focus is a neutral state, not an accent/status signal. This mix clears 3:1 + against the composer surface in both claw themes; the halo keeps it legible. */ + border-color: color-mix(in srgb, var(--muted) 72%, var(--border)); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--muted) 14%, transparent), + var(--shadow-sm); } .agent-chat__input--prefill-attention {