fix(ui): theme board fixture dropdowns (#111998)

This commit is contained in:
Peter Steinberger
2026-07-21 07:22:16 -07:00
committed by GitHub
parent 3dfb4c9cd7
commit 025e6ceee0
3 changed files with 244 additions and 5 deletions
+24 -5
View File
@@ -51,16 +51,35 @@ const boardFixtureHtml = `<!doctype html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>OpenClaw Board Fixture</title>
<script>
// This standalone fixture bypasses app bootstrap, so mirror its root theme contract.
const mediaQuery = matchMedia("(prefers-color-scheme: light)");
const applyTheme = () => {
const mode = mediaQuery.matches ? "light" : "dark";
document.documentElement.dataset.theme = mode;
document.documentElement.dataset.themeMode = mode;
document.documentElement.classList.toggle("wa-light", mode === "light");
document.documentElement.classList.toggle("wa-dark", mode === "dark");
document.documentElement.style.colorScheme = mode;
};
applyTheme();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", applyTheme);
} else {
mediaQuery.addListener(applyTheme);
}
</script>
<link rel="stylesheet" href="/src/styles.css" />
<style>
body { margin: 0; min-width: 320px; min-height: 100vh; background: #0f1115; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); }
.board-fixture-shell { box-sizing: border-box; margin: 0 auto; max-width: 1440px; padding: 36px; }
.board-fixture-header { align-items: end; display: flex; justify-content: space-between; margin-bottom: 24px; }
.board-fixture-header span { color: #747e8d; font: 10px ui-monospace, monospace; letter-spacing: .15em; }
.board-fixture-header h1 { color: #e5e7eb; font-size: 24px; letter-spacing: -.03em; margin: 5px 0 0; }
.board-fixture-status { color: #8892a0; font: 11px ui-monospace, monospace; }
.board-fixture-status i { background: #4ec9a8; border-radius: 50%; display: inline-block; height: 7px; margin-right: 6px; width: 7px; }
.board-fixture-header span { color: var(--muted); font: 10px ui-monospace, monospace; letter-spacing: .15em; }
.board-fixture-header h1 { color: var(--text-strong); font-size: 24px; letter-spacing: -.03em; margin: 5px 0 0; }
.board-fixture-status { color: var(--muted); font: 11px ui-monospace, monospace; }
.board-fixture-status i { background: var(--accent-2); border-radius: 50%; display: inline-block; height: 7px; margin-right: 6px; width: 7px; }
@media (max-width: 700px) { .board-fixture-shell { padding: 18px; } }
</style>
</head>
+216
View File
@@ -0,0 +1,216 @@
import { spawn, type ChildProcessByStdio } from "node:child_process";
import { once } from "node:events";
import { type AddressInfo, createServer } from "node:net";
import path from "node:path";
import type { Readable } from "node:stream";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
} from "../test-helpers/control-ui-e2e.ts";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeBoardFixture = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
type FixtureProcess = ChildProcessByStdio<null, Readable, Readable>;
type FixtureServer = {
child: FixtureProcess;
url: string;
output: () => string;
};
async function reservePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;
}
async function startFixtureServer(): Promise<FixtureServer> {
const port = await reservePort();
const url = `http://127.0.0.1:${port}/__fixtures/board/`;
const child = spawn(
process.execPath,
[
"--import",
"tsx",
"scripts/control-ui-mock-dev.ts",
"--host",
"127.0.0.1",
"--port",
String(port),
],
{
cwd: repoRoot,
env: { ...process.env, CI: "1" },
stdio: ["ignore", "pipe", "pipe"],
},
);
let output = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
output += chunk;
});
child.stderr.on("data", (chunk: string) => {
output += chunk;
});
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`board fixture server exited before startup\n${output}`);
}
try {
const response = await fetch(url);
if (response.ok) {
return { child, url, output: () => output };
}
} catch {}
await delay(100);
}
child.kill("SIGTERM");
throw new Error(`timed out waiting for board fixture server\n${output}`);
}
async function stopFixtureServer(server: FixtureServer | undefined): Promise<void> {
if (!server || server.child.exitCode !== null || server.child.signalCode !== null) {
return;
}
const exited = once(server.child, "exit");
server.child.kill("SIGTERM");
await Promise.race([exited, delay(5_000)]);
if (server.child.exitCode === null && server.child.signalCode === null) {
server.child.kill("SIGKILL");
await exited;
}
}
function colorChannelToLinear(channel: number): number {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
}
function colorLuminance(color: string): number {
const match = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/);
if (!match) {
throw new Error(`unsupported computed color: ${color}`);
}
if (match[4] !== undefined && Number(match[4]) !== 1) {
throw new Error(`transparent computed color requires compositing: ${color}`);
}
const channels = match.slice(1, 4).map(Number);
return (
0.2126 * colorChannelToLinear(channels[0]!) +
0.7152 * colorChannelToLinear(channels[1]!) +
0.0722 * colorChannelToLinear(channels[2]!)
);
}
function colorContrast(foreground: string, background: string): number {
const lighter = Math.max(colorLuminance(foreground), colorLuminance(background));
const darker = Math.min(colorLuminance(foreground), colorLuminance(background));
return (lighter + 0.05) / (darker + 0.05);
}
async function openWidgetMenu(page: Page): Promise<void> {
const widget = page.locator('[data-test-id="board-widget"]').first();
await widget.focus();
await widget.locator(".board-widget__menu-trigger").click();
await page.locator(".board-widget__menu[open]").waitFor();
}
async function readMenuColors(page: Page): Promise<{ background: string; foreground: string }> {
return page.evaluate(() => {
const dropdown = document.querySelector(".board-widget__menu");
const item = dropdown?.querySelector("wa-dropdown-item:not(.board-widget__menu-danger)");
const menu = dropdown?.shadowRoot?.querySelector('[part~="menu"]');
if (!(item instanceof HTMLElement) || !(menu instanceof HTMLElement)) {
throw new Error("board fixture menu did not expose its surface and first item");
}
return {
background: getComputedStyle(menu).backgroundColor,
foreground: getComputedStyle(item).color,
};
});
}
let browser: Browser;
let fixtureServer: FixtureServer;
describeBoardFixture("standalone board fixture", () => {
beforeAll(async () => {
fixtureServer = await startFixtureServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
});
afterAll(async () => {
await browser?.close();
await stopFixtureServer(fixtureServer);
});
for (const mode of ["dark", "light"] as const) {
it(`themes dropdown items and widget frames in ${mode} mode`, async () => {
const context = await browser.newContext({ colorScheme: mode });
try {
const page = await context.newPage();
await page.goto(fixtureServer.url, { waitUntil: "networkidle" });
await expect
.poll(() =>
page.locator("html").evaluate((root) => ({
classes: [...root.classList],
theme: (root as HTMLElement).dataset.theme,
themeMode: (root as HTMLElement).dataset.themeMode,
})),
)
.toEqual({ classes: [`wa-${mode}`], theme: mode, themeMode: mode });
await openWidgetMenu(page);
const colors = await readMenuColors(page);
expect(colorContrast(colors.foreground, colors.background)).toBeGreaterThanOrEqual(4.5);
await expect
.poll(() => page.frames().some((frame) => frame.url().startsWith("data:text/html")))
.toBe(true);
const widgetFrame = page.frames().find((frame) => frame.url().startsWith("data:text/html"));
expect(widgetFrame).toBeDefined();
await expect
.poll(() =>
widgetFrame!.evaluate(() => getComputedStyle(document.documentElement).colorScheme),
)
.toBe(mode);
} finally {
await context.close();
}
});
}
it("follows live system color-scheme changes", async () => {
const context = await browser.newContext({ colorScheme: "dark" });
try {
const page = await context.newPage();
await page.goto(fixtureServer.url, { waitUntil: "networkidle" });
await page.emulateMedia({ colorScheme: "light" });
await expect.poll(() => page.locator("html").getAttribute("data-theme-mode")).toBe("light");
await expect.poll(() => page.locator("html").getAttribute("class")).toBe("wa-light");
} finally {
await context.close();
}
});
});
+4
View File
@@ -160,6 +160,10 @@ function frameUrl(name: string): string {
font-weight:650;letter-spacing:-.06em;margin-top:16px}.detail{color:#77cdb5;font-size:11px;margin-top:9px}
.ticks{display:flex;align-items:end;gap:5px;height:52px;margin-top:20px}.ticks i{display:block;flex:1;
min-width:4px;border-radius:2px 2px 0 0;background:#39434d}.ticks i:nth-child(3n){background:#4ec9a8}
@media(prefers-color-scheme:light){:root{color-scheme:light}body{color:#403c35;background:
radial-gradient(circle at 85% 5%,rgba(20,184,166,.12),transparent 38%),#f4f1ec}
.eyebrow{color:#6e6960}.detail{color:#0f766e}.ticks i{background:#d8d1c7}
.ticks i:nth-child(3n){background:#14b8a6}}
</style><div class="eyebrow">${copy.eyebrow}</div><div class="value">${copy.value}</div>
<div class="detail">${copy.detail}</div><div class="ticks">${[28, 52, 37, 68, 44, 81, 59, 72]
.map((height) => `<i style="height:${height}%"></i>`)