mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): theme board fixture dropdowns (#111998)
This commit is contained in:
committed by
GitHub
parent
3dfb4c9cd7
commit
025e6ceee0
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>`)
|
||||
|
||||
Reference in New Issue
Block a user