feat(ui): refine gem-blue acrylic palette and focus borders
Derive the gem-blue palette with OKLCH tonal and complementary-color rules while preserving acrylic opacity, blur and surface hierarchy. Darker focus tokens exposed stacked field rings, fallback outlines and persistent basemap selection rings. Keep fallback focus styles in the base layer and use one themed field boundary or inset card stroke. Preserve keyboard focus, invalid states and native input semantics. Add browser regression coverage for pointer and keyboard focus, all basemap presentations, narrow layouts and rendered contrast. Validation in an isolated checkout: pnpm lint, pnpm typecheck, pnpm test (393 tests), focused browser suites and pnpm build.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { expectThemeColor, readColorAlpha, readThemeColor } from "./support/theme-colors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockWorkbenchContext(page);
|
||||
@@ -53,18 +54,18 @@ test("desktop Agent docks beside the workspace with a solid reading surface", as
|
||||
await waitForAnimations(agentPanel);
|
||||
|
||||
await expectAcrylicSurface(topBar);
|
||||
await expect(agentPanel).toHaveCSS("background-color", "oklch(0.972 0.005 250)");
|
||||
await expectThemeColor(agentPanel, "background-color", "--agent-dock-surface");
|
||||
await expect(agentPanel).toHaveCSS("backdrop-filter", "none");
|
||||
await expect(agentPanel).toHaveCSS("border-radius", "0px");
|
||||
await expect(agentPanel).toHaveCSS("border-top-width", "0px");
|
||||
await expect(agentPanel).toHaveCSS("border-bottom-width", "0px");
|
||||
await expect(agentPanel).toHaveCSS("border-left-width", "0px");
|
||||
await expect(agentPanel).toHaveCSS("border-right-width", "1px");
|
||||
await expect(agentPanel).toHaveCSS("border-right-color", "oklch(0.905 0.009 250)");
|
||||
await expectThemeColor(agentPanel, "border-right-color", "--surface-divider");
|
||||
await expect(agentPanel).toHaveCSS("box-shadow", "none");
|
||||
await expect(agentControls).toHaveCSS("border-top-width", "1px");
|
||||
await expect(agentControls).toHaveCSS("background-color", "oklch(0.985 0.003 250)");
|
||||
await expect(agentPanel.locator("form.agent-panel-control")).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expectThemeColor(agentControls, "background-color", "--agent-dock-band");
|
||||
await expectThemeColor(agentPanel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
|
||||
const capabilities = agentPanel.getByRole("list", { name: "Agent 分析能力" });
|
||||
await expect(capabilities.getByRole("listitem")).toHaveCount(4);
|
||||
await expect(capabilities).toHaveCSS("box-shadow", "none");
|
||||
@@ -137,7 +138,7 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toHaveAttribute("data-agent-presentation", "desktop-dock");
|
||||
expect(await menu.evaluate((element) => element.closest("aside"))).toBeNull();
|
||||
await expect(menu).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)");
|
||||
await expectThemeColor(menu, "background-color", "--agent-dock-overlay-surface");
|
||||
await expect(menu).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
|
||||
await expect(menu).toHaveCSS("background-image", "none");
|
||||
await page.keyboard.press("Escape");
|
||||
@@ -151,12 +152,12 @@ test("desktop Agent portal menus keep local material and respect reduced transpa
|
||||
expect(await page.evaluate(() => matchMedia("(prefers-reduced-transparency: reduce)").matches)).toBe(true);
|
||||
await panel.getByRole("button", { name: "选择 Agent 模型" }).click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expectThemeColor(menu, "background-color", "--palette-paper");
|
||||
await expect(menu).toHaveCSS("backdrop-filter", "none");
|
||||
await page.keyboard.press("Escape");
|
||||
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
const history = panel.getByRole("region", { name: "Agent 历史记录" });
|
||||
await expect(history).toHaveCSS("background-color", "rgb(255, 255, 255)");
|
||||
await expectThemeColor(history, "background-color", "--palette-paper");
|
||||
await expect(history).toHaveCSS("backdrop-filter", "none");
|
||||
});
|
||||
|
||||
@@ -176,7 +177,7 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
|
||||
await waitForAnimations(history);
|
||||
await expect(historyButton).toHaveAttribute("aria-expanded", "true");
|
||||
await expectUnifiedHistoryStructure(history);
|
||||
await expect(history).toHaveCSS("background-color", "rgba(255, 255, 255, 0.94)");
|
||||
await expectThemeColor(history, "background-color", "--agent-dock-overlay-surface");
|
||||
await expect(history).toHaveCSS("backdrop-filter", "blur(12px) saturate(1.03)");
|
||||
|
||||
await history.getByRole("button", { name: "重命名对话" }).first().click();
|
||||
@@ -186,7 +187,7 @@ test("desktop Agent history remains usable inside the docked navigation pane", a
|
||||
);
|
||||
await expect(renameInput).toBeFocused();
|
||||
await expect(renameInput).toHaveCSS("box-shadow", "none");
|
||||
await expect(renameInput).toHaveCSS("border-color", "rgb(203, 213, 225)");
|
||||
await expectThemeColor(renameInput, "border-color", "--action-blue-hover");
|
||||
await expect(editingItem).toHaveCSS("border-color", "rgba(0, 0, 0, 0)");
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/desktop-rename.png" });
|
||||
await history.getByRole("button", { name: "取消重命名" }).click();
|
||||
@@ -414,6 +415,7 @@ test("long history titles keep rename and explicit delete usable across viewport
|
||||
await history.getByRole("button", { name: "重命名对话", exact: true }).click();
|
||||
const input = history.getByRole("textbox");
|
||||
await expect(input).toBeFocused();
|
||||
await expectThemeColor(input, "border-color", "--action-blue-hover");
|
||||
await input.fill(`${originalTitle}(已复核)`);
|
||||
await expect(history.getByRole("button", { name: "保存对话主题" })).toBeVisible();
|
||||
await waitForAnimations(history);
|
||||
@@ -632,9 +634,7 @@ async function expectTransparentExpandedHeader(header: Locator, agentSurface: Lo
|
||||
boxShadow: style.boxShadow
|
||||
};
|
||||
}),
|
||||
agentSurface.evaluate((element) =>
|
||||
getComputedStyle(element).getPropertyValue("--acrylic-outline").trim()
|
||||
)
|
||||
readThemeColor(agentSurface, "--acrylic-outline")
|
||||
]);
|
||||
|
||||
expect(getAlpha(presentation.backgroundColor)).toBe(0);
|
||||
@@ -665,9 +665,7 @@ async function getSurfacePresentation(surface: Locator) {
|
||||
}
|
||||
|
||||
function getAlpha(color: string) {
|
||||
const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
|
||||
|
||||
return match ? Number(match[1]) : 1;
|
||||
return readColorAlpha(color);
|
||||
}
|
||||
|
||||
async function expectLightweightMaterialContext(context: Locator) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { expectThemeColor } from "./support/theme-colors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
});
|
||||
|
||||
for (const width of [320, 375, 1440]) {
|
||||
test(`layer panel basemap selection has one border and no persistent outer ring at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
||||
const panel = page.getByRole("region", { name: "图层", exact: true });
|
||||
const card = panel.getByRole("button", { name: "无底图", exact: true });
|
||||
await expect(card).toHaveAttribute("aria-pressed", "true");
|
||||
await expectThemeColor(card, "border-color", "--action-blue");
|
||||
await expect(card).toHaveCSS("border-width", "1px");
|
||||
await expect(card).toHaveCSS("box-shadow", "none");
|
||||
await card.click();
|
||||
await expect(card).toHaveCSS("outline-style", "none");
|
||||
const restingBox = await card.boundingBox();
|
||||
await page.keyboard.press("Tab");
|
||||
await card.focus();
|
||||
await expect(card).toHaveCSS("outline-width", "1px");
|
||||
await expect(card).toHaveCSS("outline-offset", "-1px");
|
||||
await expectThemeColor(card, "outline-color", "--action-focus-ring");
|
||||
expect(await card.boundingBox()).toEqual(restingBox);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(card).toHaveAttribute("aria-pressed", "true");
|
||||
await panel.screenshot({ path: `test-results/form-focus/layer-panel-${width}.png` });
|
||||
});
|
||||
}
|
||||
|
||||
test("standalone basemap controls share the same pointer and keyboard boundary", async ({ page }) => {
|
||||
await renderControls(page);
|
||||
for (const label of ["面板底图", "浮动底图"]) {
|
||||
const control = page.getByRole("region", { name: label, exact: true });
|
||||
if (label === "浮动底图") await control.getByRole("button", { name: "浮动底图:当前 浅色" }).hover();
|
||||
const selected = control.getByRole("button", { name: "浅色", exact: true });
|
||||
await expect(selected).toHaveCSS("border-width", "1px");
|
||||
await expectThemeColor(selected, "border-color", "--action-blue");
|
||||
const restingShadow = await selected.evaluate((element) => getComputedStyle(element).boxShadow);
|
||||
expect(restingShadow).not.toMatch(/0px 0px 0px [12]px/);
|
||||
await selected.click();
|
||||
await expect(selected).toHaveCSS("outline-style", "none");
|
||||
await page.keyboard.press("Tab");
|
||||
await selected.focus();
|
||||
await expect(selected).toHaveCSS("outline-width", "1px");
|
||||
await expect(selected).toHaveCSS("outline-offset", "-1px");
|
||||
await expect(selected).toHaveCSS("box-shadow", restingShadow);
|
||||
const next = control.getByRole("button", { name: "影像", exact: true });
|
||||
await next.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(next).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(selected).toHaveAttribute("aria-pressed", "false");
|
||||
await control.screenshot({ path: `test-results/form-focus/${label}.png` });
|
||||
}
|
||||
});
|
||||
|
||||
async function renderControls(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const paths = ["/node_modules/.vite/deps/react.js", "/node_modules/.vite/deps/react-dom_client.js", "/src/features/map/core/components/base-layers-control.tsx"];
|
||||
const [react, dom, controls] = await Promise.all(paths.map((path) => import(path)));
|
||||
const h = react.default.createElement;
|
||||
function Examples() {
|
||||
const [panelId, setPanelId] = react.default.useState("light");
|
||||
const [floatingId, setFloatingId] = react.default.useState("light");
|
||||
const options = [{ id: "light", label: "浅色" }, { id: "satellite", label: "影像" }];
|
||||
return h(react.default.Fragment, null,
|
||||
h(controls.BaseLayersControl, { title: "面板底图", variant: "panel", options, activeLayerId: panelId, onSelectLayer: setPanelId }),
|
||||
h(controls.BaseLayersControl, { title: "浮动底图", options, activeLayerId: floatingId, onSelectLayer: setFloatingId })
|
||||
);
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "position:fixed;z-index:40;top:80px;right:40px;padding:24px;background:var(--surface-reading);display:grid;gap:32px";
|
||||
document.body.append(container);
|
||||
dom.default.createRoot(container).render(h(Examples));
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockScadaApi } from "./support/mock-scada-api";
|
||||
import { readThemeColor } from "./support/theme-colors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await prepareWorkbench(page);
|
||||
@@ -32,7 +33,7 @@ test("desktop controls share focus, hover, press and open states", async ({ page
|
||||
const restingBackground = await pressTarget.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor
|
||||
);
|
||||
expect(restingBackground).toBe("rgb(232, 241, 255)");
|
||||
expect(restingBackground).toBe(await readThemeColor(pressTarget, "--action-selection-soft"));
|
||||
await pressTarget.hover();
|
||||
const box = await pressTarget.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
@@ -48,7 +49,7 @@ test("desktop controls share focus, hover, press and open states", async ({ page
|
||||
expect(pressedBox).toEqual(box);
|
||||
await expect
|
||||
.poll(async () => pressTarget.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe("rgb(207, 226, 255)");
|
||||
.toBe(await readThemeColor(pressTarget, "--action-selection-soft-pressed"));
|
||||
await page.mouse.move(720, 460);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { expectThemeColor } from "./support/theme-colors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
});
|
||||
|
||||
test("Agent composer keeps one thin focus boundary without an outer blue ring", async ({ page }) => {
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const form = page.locator("form.agent-panel-control");
|
||||
const textarea = page.getByPlaceholder(/输入调度问题/);
|
||||
const restingShadow = await form.evaluate((element) => getComputedStyle(element).boxShadow);
|
||||
expect(restingShadow).not.toBe("none");
|
||||
const restingBox = await form.boundingBox();
|
||||
await textarea.click();
|
||||
await expectThemeColor(form, "border-color", "--action-blue-hover");
|
||||
await expect(form).toHaveCSS("border-width", "1px");
|
||||
await expect(form).toHaveCSS("box-shadow", restingShadow);
|
||||
await expect(textarea).toHaveCSS("outline-style", "none");
|
||||
expect(await form.boundingBox()).toEqual(restingBox);
|
||||
await page.keyboard.press("Tab");
|
||||
await textarea.focus();
|
||||
await expectThemeColor(form, "border-color", "--action-blue-hover");
|
||||
const focusedBox = await form.boundingBox();
|
||||
await page.screenshot({
|
||||
path: "test-results/form-focus/agent-focused.png",
|
||||
clip: { x: focusedBox!.x - 4, y: focusedBox!.y - 4, width: focusedBox!.width + 8, height: focusedBox!.height + 8 }
|
||||
});
|
||||
});
|
||||
|
||||
test("shared fields and grouped inputs do not stack native outlines and focus rings", async ({ page }) => {
|
||||
await renderFields(page);
|
||||
for (const label of ["共享输入", "共享多行输入", "组合输入", "选择字段", "日期字段"]) {
|
||||
const control = page.getByLabel(label, { exact: true });
|
||||
await page.keyboard.press("Tab");
|
||||
await control.focus();
|
||||
await expect(control).toHaveCSS("outline-style", "none");
|
||||
const owner = label === "组合输入" ? page.getByRole("group", { name: "组合输入区" }) : control;
|
||||
await expectThemeColor(owner, "border-color", "--action-blue-hover");
|
||||
await expect(owner).toHaveCSS("border-width", "1px");
|
||||
const shadow = await owner.evaluate((element) => getComputedStyle(element).boxShadow);
|
||||
expect(shadow).not.toMatch(/0px 0px 0px [12]px/);
|
||||
}
|
||||
const input = page.getByLabel("共享输入", { exact: true });
|
||||
await input.evaluate((element) => element.setAttribute("aria-invalid", "true"));
|
||||
await input.focus();
|
||||
await expectThemeColor(input, "border-color", "--action-danger");
|
||||
const groupedInput = page.getByLabel("组合输入", { exact: true });
|
||||
await groupedInput.evaluate((element) => element.setAttribute("aria-invalid", "true"));
|
||||
await groupedInput.focus();
|
||||
await expectThemeColor(page.getByRole("group", { name: "组合输入区" }), "border-color", "--action-danger");
|
||||
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/shared-fields.png" });
|
||||
});
|
||||
|
||||
test("borderless selects and choice controls keep one theme keyboard focus indicator", async ({ page }) => {
|
||||
await renderFields(page);
|
||||
const borderless = page.getByRole("combobox", { name: "无边框选择" });
|
||||
await page.keyboard.press("Tab");
|
||||
await borderless.focus();
|
||||
await expect(borderless).toHaveCSS("border-width", "0px");
|
||||
await expect(borderless).toHaveCSS("outline-style", "solid");
|
||||
await expect(borderless).toHaveCSS("outline-width", "1px");
|
||||
await expect(borderless).toHaveCSS("outline-offset", "-1px");
|
||||
await expectThemeColor(borderless, "outline-color", "--action-blue-hover");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("option", { name: "第二个选项" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(borderless).toBeFocused();
|
||||
|
||||
const firstRadio = page.getByRole("radio", { name: "单选一" });
|
||||
await firstRadio.focus();
|
||||
await expect(firstRadio).toHaveCSS("appearance", "none");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByRole("radio", { name: "单选二" })).toBeChecked();
|
||||
const checkbox = page.getByRole("checkbox", { name: "多选" });
|
||||
await checkbox.focus();
|
||||
await expect(checkbox).toHaveCSS("appearance", "none");
|
||||
await page.keyboard.press("Space");
|
||||
await expect(checkbox).toBeChecked();
|
||||
await expect(checkbox).toHaveCSS("outline-style", "none");
|
||||
const shadow = await checkbox.evaluate((element) => getComputedStyle(element).boxShadow);
|
||||
expect(shadow).toContain("0px 0px 0px 4px");
|
||||
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/choice-fields.png" });
|
||||
});
|
||||
|
||||
for (const width of [320, 375]) {
|
||||
test(`mobile composer owns a single inset focus stroke at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
const textarea = page.getByPlaceholder(/输入调度问题/);
|
||||
await textarea.click();
|
||||
const material = page.locator(".agent-panel-composer");
|
||||
await expect(material).toHaveCSS("outline-width", "1px");
|
||||
await expect(material).toHaveCSS("outline-offset", "-1px");
|
||||
await expectThemeColor(material, "outline-color", "--action-blue-hover");
|
||||
await expect(textarea).toHaveCSS("outline-style", "none");
|
||||
await page.screenshot({ path: `test-results/form-focus/mobile-${width}.png` });
|
||||
});
|
||||
}
|
||||
|
||||
async function renderFields(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
// Load the production components through Vite, so the test exercises their real CSS.
|
||||
const paths = ["/node_modules/.vite/deps/react.js", "/node_modules/.vite/deps/react-dom_client.js",
|
||||
"/src/shared/ui/input.tsx", "/src/shared/ui/textarea.tsx", "/src/shared/ui/input-group.tsx",
|
||||
"/src/shared/ui/field-select.tsx", "/src/shared/ui/date-time-field.tsx", "/src/shared/ui/select.tsx",
|
||||
"/src/shared/ui/checkbox.tsx", "/src/shared/ui/radio.tsx", "/src/shared/ai-elements/prompt-input.tsx"];
|
||||
const [react, dom, inputs, textareas, groups, selects, dates, primitives, checkboxes, radios, prompts] = await Promise.all(paths.map((path) => import(path)));
|
||||
const h = react.default.createElement;
|
||||
const container = document.createElement("div");
|
||||
container.id = "focus-fields";
|
||||
container.style.cssText = "position:fixed;z-index:40;top:80px;left:80px;width:360px;padding:24px;background:var(--surface-reading);display:grid;gap:16px";
|
||||
document.body.append(container);
|
||||
dom.default.createRoot(container).render(h(react.default.Fragment, null,
|
||||
h(inputs.Input, { "aria-label": "共享输入", placeholder: "共享输入" }),
|
||||
h(textareas.Textarea, { "aria-label": "共享多行输入", placeholder: "共享多行输入" }),
|
||||
h(groups.InputGroup, { "aria-label": "组合输入区" }, h(groups.InputGroupInput, { "aria-label": "组合输入", placeholder: "组合输入" })),
|
||||
h(selects.FieldSelect, { label: "选择字段", value: "first", onValueChange: () => {}, options: [{ value: "first", label: "第一个选项" }, { value: "second", label: "第二个选项" }] }),
|
||||
h(dates.DateTimeField, { label: "日期字段", value: "2026-10-09T09:00", onValueChange: () => {} }),
|
||||
h(primitives.Select, { defaultValue: "first" },
|
||||
h(prompts.PromptInputSelectTrigger, { "aria-label": "无边框选择" }, h(primitives.SelectValue)),
|
||||
h(primitives.SelectContent, null,
|
||||
h(primitives.SelectItem, { value: "first" }, "第一个选项"),
|
||||
h(primitives.SelectItem, { value: "second" }, "第二个选项")
|
||||
)
|
||||
),
|
||||
h("div", { style: { display: "flex", gap: "16px" } },
|
||||
h("label", null, h(radios.Radio, { name: "focus-radio", defaultChecked: true }), "单选一"),
|
||||
h("label", null, h(radios.Radio, { name: "focus-radio" }), "单选二"),
|
||||
h("label", null, h(checkboxes.Checkbox), "多选")
|
||||
)
|
||||
));
|
||||
});
|
||||
await expect(page.getByLabel("共享输入", { exact: true })).toBeVisible();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expectThemeColor, readColorAlpha, readThemeColor } from "./support/theme-colors";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import {
|
||||
@@ -114,10 +115,10 @@ test("assigned desktop panels avoid native form controls and horizontal overflow
|
||||
const meterUnit = measurePanel.getByRole("button", { name: "m", exact: true });
|
||||
const kilometerUnit = measurePanel.getByRole("button", { name: "km", exact: true });
|
||||
await expect(kilometerUnit).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(kilometerUnit).toHaveClass(/bg-blue-600/);
|
||||
await expectThemeColor(kilometerUnit, "background-color", "--action-blue");
|
||||
await meterUnit.click();
|
||||
await expect(meterUnit).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(meterUnit).toHaveClass(/bg-blue-600/);
|
||||
await expectThemeColor(meterUnit, "background-color", "--action-blue");
|
||||
await kilometerUnit.click();
|
||||
const distanceMode = measurePanel.getByRole("button", { name: "距离", exact: true });
|
||||
const areaMode = measurePanel.getByRole("button", { name: "面积", exact: true });
|
||||
@@ -136,14 +137,14 @@ test("assigned desktop panels avoid native form controls and horizontal overflow
|
||||
await expect(measurePanel.getByRole("button", { name: "清除测量" })).toBeDisabled();
|
||||
await expect(measurePanel.getByText("等待开始", { exact: true })).toBeVisible();
|
||||
const startMeasureButton = measurePanel.getByRole("button", { name: /开始测量/ });
|
||||
await expect(startMeasureButton).toHaveClass(/bg-slate-50\/90/);
|
||||
await expect(startMeasureButton).not.toHaveClass(/bg-blue-600/);
|
||||
await expect.poll(async () => readColorAlpha(await startMeasureButton.evaluate((element) => getComputedStyle(element).backgroundColor))).toBeCloseTo(0.864, 2);
|
||||
await expect(startMeasureButton).not.toHaveCSS("background-color", await readThemeColor(startMeasureButton, "--action-blue"));
|
||||
await expect(startMeasureButton).toContainText("在地图中连续取点");
|
||||
await startMeasureButton.click();
|
||||
const stopMeasureButton = measurePanel.getByRole("button", { name: /停止测量/ });
|
||||
await expect(stopMeasureButton).toBeVisible();
|
||||
await expect(stopMeasureButton).toHaveAttribute("data-state", "active");
|
||||
await expect(stopMeasureButton).toHaveClass(/border-blue-300/);
|
||||
await expectThemeColor(stopMeasureButton, "border-color", "--palette-brand");
|
||||
await expect(stopMeasureButton).toContainText("暂停并保留当前读数");
|
||||
await expect(stopMeasureButton).toContainText("进行中");
|
||||
await expect(measurePanel.getByText("测量中", { exact: true })).toBeVisible();
|
||||
@@ -152,10 +153,11 @@ test("assigned desktop panels avoid native form controls and horizontal overflow
|
||||
fullPage: true
|
||||
});
|
||||
await stopMeasureButton.click();
|
||||
await page.mouse.move(640, 56);
|
||||
const resumedStartMeasureButton = measurePanel.getByRole("button", { name: /开始测量/ });
|
||||
await expect(resumedStartMeasureButton).toBeEnabled();
|
||||
await expect(resumedStartMeasureButton).toHaveClass(/bg-slate-50\/90/);
|
||||
await expect(resumedStartMeasureButton).not.toHaveClass(/bg-blue-600/);
|
||||
await expect.poll(async () => readColorAlpha(await resumedStartMeasureButton.evaluate((element) => getComputedStyle(element).backgroundColor))).toBeCloseTo(0.864, 2);
|
||||
await expect(resumedStartMeasureButton).not.toHaveCSS("background-color", await readThemeColor(resumedStartMeasureButton, "--action-blue"));
|
||||
await page.screenshot({
|
||||
path: "test-results/non-map-panels/1280-measure-panel.png",
|
||||
fullPage: true
|
||||
@@ -336,6 +338,23 @@ async function expectContained(locator: Locator, viewportWidth: number) {
|
||||
}
|
||||
|
||||
async function prepareWorkbench(page: Page) {
|
||||
// Layout fixtures keep the event stream open, just like the live backend.
|
||||
await page.addInitScript(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.includes("/api/v1/workbench/events")) {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(": fixture keepalive\n\n"));
|
||||
init?.signal?.addEventListener("abort", () => controller.close(), { once: true });
|
||||
}
|
||||
});
|
||||
return Promise.resolve(new Response(body, { headers: { "Content-Type": "text/event-stream" } }));
|
||||
}
|
||||
return fetch(input, init);
|
||||
};
|
||||
});
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/runtime-config.js", (route) =>
|
||||
route.fulfill({
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { expectThemeColor, readColorAlpha, readRenderedContrast, readThemeColor } from "./support/theme-colors";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
});
|
||||
|
||||
test("gem-blue actions and semantic labels remain readable on the new surfaces", async ({ page }) => {
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await expectThemeColor(panel, "background-color", "--agent-dock-surface");
|
||||
const tint = await panel.evaluate((element) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.fillStyle = getComputedStyle(element).backgroundColor;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
return Array.from(context.getImageData(0, 0, 1, 1).data);
|
||||
});
|
||||
// Low-chroma material interpolation must stay in the cool silver/blue family.
|
||||
expect(tint[2]).toBeGreaterThan(tint[1]);
|
||||
expect(tint[2]).toBeGreaterThan(tint[0]);
|
||||
await expectThemeColor(panel.locator("form.agent-panel-control"), "background-color", "--surface-reading");
|
||||
const body = panel.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。");
|
||||
expect(await readRenderedContrast(body)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(await readRenderedContrast(panel.getByText("对话主题"))).toBeGreaterThanOrEqual(4.5);
|
||||
|
||||
await page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应").fill("检查压力变化");
|
||||
const send = panel.getByRole("button", { name: "发送 Agent 指令", exact: true });
|
||||
await expect(send).toBeEnabled();
|
||||
await expectThemeColor(send, "background-color", "--action-blue");
|
||||
expect(await readRenderedContrast(send)).toBeGreaterThanOrEqual(4.5);
|
||||
await send.hover();
|
||||
await expectThemeColor(send, "background-color", "--action-blue-hover");
|
||||
expect(await readRenderedContrast(send)).toBeGreaterThanOrEqual(4.5);
|
||||
|
||||
// Exercise every shared semantic tone after CSS variables and color-mix are resolved.
|
||||
await panel.evaluate((element) => {
|
||||
const samples = document.createElement("div");
|
||||
samples.dataset.paletteSamples = "true";
|
||||
for (const tone of ["neutral", "info", "success", "warning", "danger"]) {
|
||||
const label = document.createElement("span");
|
||||
label.className = `status-badge status-tone-${tone}`;
|
||||
label.dataset.paletteTone = tone;
|
||||
label.textContent = tone;
|
||||
samples.append(label);
|
||||
}
|
||||
element.append(samples);
|
||||
});
|
||||
for (const tone of ["neutral", "info", "success", "warning", "danger"]) {
|
||||
expect(await readRenderedContrast(panel.locator(`[data-palette-tone="${tone}"]`)), tone)
|
||||
.toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
test("selected controls preserve readable hover, pressed and keyboard focus states", async ({ page }) => {
|
||||
const control = page.getByRole("button", { name: /任务浮条/ });
|
||||
await expect(control).toHaveAttribute("aria-pressed", "true");
|
||||
expect(await readRenderedContrast(control)).toBeGreaterThanOrEqual(4.5);
|
||||
await control.hover();
|
||||
await expectThemeColor(control, "background-color", "--action-selection-soft-hover");
|
||||
expect(await readRenderedContrast(control)).toBeGreaterThanOrEqual(4.5);
|
||||
const box = (await control.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await expectThemeColor(control, "background-color", "--action-selection-soft-pressed");
|
||||
expect(await readRenderedContrast(control)).toBeGreaterThanOrEqual(4.5);
|
||||
await page.mouse.move(720, 460);
|
||||
await page.mouse.up();
|
||||
await page.keyboard.press("Tab");
|
||||
await control.focus();
|
||||
expect(await control.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
|
||||
expect(await control.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe("none");
|
||||
});
|
||||
|
||||
test("floating panel text stays readable over light and dark map backgrounds", async ({ page }) => {
|
||||
// A solid underlay gives the transparent material a known actual backdrop.
|
||||
await page.addStyleTag({ content: "#workspace-map .maplibregl-canvas { visibility: hidden; } .acrylic-panel { background-image: none; }" });
|
||||
await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
|
||||
const layers = page.getByRole("region", { name: "图层", exact: true });
|
||||
await expect(layers).toBeVisible();
|
||||
const title = layers.getByText("图层", { exact: true }).first();
|
||||
for (const tone of ["light", "satellite"]) {
|
||||
await page.locator("main").evaluate((element, value) => {
|
||||
element.setAttribute("data-basemap-tone", value);
|
||||
(element as HTMLElement).style.backgroundColor = value === "light" ? "#f5f7fa" : "#172b3a";
|
||||
}, tone);
|
||||
await expectThemeColor(layers, "background-color", "--acrylic-panel");
|
||||
expect(await readRenderedContrast(title), tone).toBeGreaterThanOrEqual(4.5);
|
||||
const color = await layers.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
expect(readColorAlpha(color)).toBeCloseTo(tone === "light" ? 0.56 : 0.72, 2);
|
||||
await expect(layers).toHaveCSS("backdrop-filter", "blur(24px) saturate(" + (tone === "light" ? "1.08" : "1.02") + ")");
|
||||
}
|
||||
});
|
||||
|
||||
for (const width of [320, 375, 390]) {
|
||||
test(`new palette keeps Agent input and controls usable at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
const composer = panel.locator("form.agent-panel-control");
|
||||
await expect(composer).toBeVisible();
|
||||
const material = panel.locator(".agent-panel-composer");
|
||||
await expectThemeColor(material, "background-color", "--agent-white-acrylic-surface");
|
||||
expect(readColorAlpha(await material.evaluate((element) => getComputedStyle(element).backgroundColor))).toBeCloseTo(0.86, 2);
|
||||
await expect(material).toHaveCSS("backdrop-filter", "blur(18px) saturate(1.08)");
|
||||
expect(await readRenderedContrast(panel.getByText("对话主题"))).toBeGreaterThanOrEqual(4.5);
|
||||
for (const element of [panel, composer]) {
|
||||
const box = (await element.boundingBox())!;
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(width);
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||
await page.screenshot({ path: `test-results/palette/agent-${width}.png` });
|
||||
});
|
||||
}
|
||||
|
||||
test("desktop palette captures dock, history, conditions and map tools", async ({ page }) => {
|
||||
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await page.screenshot({ path: "test-results/palette/desktop-1440.png" });
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.screenshot({ path: "test-results/palette/desktop-1920.png" });
|
||||
await panel.getByRole("button", { name: "打开 Agent 历史记录" }).click();
|
||||
const history = panel.getByRole("region", { name: "Agent 历史记录" });
|
||||
await expect(history).toBeVisible();
|
||||
expect(await readThemeColor(history, "--agent-dock-overlay-surface"))
|
||||
.toBe(await history.evaluate((element) => getComputedStyle(element).backgroundColor));
|
||||
await page.screenshot({ path: "test-results/palette/desktop-history.png" });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
const conditions = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await expect(conditions).toBeVisible();
|
||||
await conditions.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await page.screenshot({ path: "test-results/palette/desktop-report.png" });
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
export async function readThemeColor(locator: Locator, token: string) {
|
||||
return locator.evaluate((element, name) => {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.backgroundColor = getComputedStyle(element).getPropertyValue(name);
|
||||
document.body.append(probe);
|
||||
const color = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
return color;
|
||||
}, token);
|
||||
}
|
||||
|
||||
export function readColorAlpha(color: string) {
|
||||
const rgba = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
|
||||
const modern = color.match(/\/\s*([\d.]+)(%)?\s*\)$/);
|
||||
return rgba ? Number(rgba[1]) : modern ? Number(modern[1]) / (modern[2] ? 100 : 1) : 1;
|
||||
}
|
||||
|
||||
export async function expectThemeColor(locator: Locator, property: string, token: string) {
|
||||
await expect(locator).toHaveCSS(property, await readThemeColor(locator, token));
|
||||
}
|
||||
|
||||
// Canvas resolves RGB, OKLCH and color-mix() in the browser's sRGB output space.
|
||||
// Compose ancestor backgrounds rather than assuming that the reading surface is white.
|
||||
export async function readRenderedContrast(locator: Locator) {
|
||||
return locator.evaluate((element) => {
|
||||
const context = document.createElement("canvas").getContext("2d")!;
|
||||
context.canvas.width = 1;
|
||||
context.canvas.height = 1;
|
||||
|
||||
function rgba(color: string) {
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, alpha] = context.getImageData(0, 0, 1, 1).data;
|
||||
return { r, g, b, a: alpha / 255 };
|
||||
}
|
||||
|
||||
function over(top: ReturnType<typeof rgba>, bottom: ReturnType<typeof rgba>) {
|
||||
const a = top.a + bottom.a * (1 - top.a);
|
||||
return {
|
||||
r: (top.r * top.a + bottom.r * bottom.a * (1 - top.a)) / a,
|
||||
g: (top.g * top.a + bottom.g * bottom.a * (1 - top.a)) / a,
|
||||
b: (top.b * top.a + bottom.b * bottom.a * (1 - top.a)) / a,
|
||||
a
|
||||
};
|
||||
}
|
||||
|
||||
function luminance(color: ReturnType<typeof rgba>) {
|
||||
const linear = [color.r, color.g, color.b].map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
const ancestors: Element[] = [];
|
||||
for (let current: Element | null = element; current; current = current.parentElement) {
|
||||
ancestors.unshift(current);
|
||||
}
|
||||
let background = rgba("white");
|
||||
for (const ancestor of ancestors) {
|
||||
background = over(rgba(getComputedStyle(ancestor).backgroundColor), background);
|
||||
}
|
||||
const foreground = over(rgba(getComputedStyle(element).color), background);
|
||||
const light = Math.max(luminance(foreground), luminance(background));
|
||||
const dark = Math.min(luminance(foreground), luminance(background));
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockScadaApi } from "./support/mock-scada-api";
|
||||
import { readRenderedContrast, readThemeColor } from "./support/theme-colors";
|
||||
|
||||
const EMPTY_RASTER_TILE = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
@@ -23,6 +24,7 @@ test.describe("Main Frame workbench", () => {
|
||||
await expectSolidMainFrameMaterials(page);
|
||||
await expectLoadedChineseFont(page);
|
||||
await expectBodyContrast(page);
|
||||
await page.screenshot({ path: "test-results/palette/workbench-desktop-light.png", animations: "disabled" });
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-light.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
@@ -38,6 +40,7 @@ test.describe("Main Frame workbench", () => {
|
||||
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
||||
await expectDesktopMainFrameGeometry(page);
|
||||
await expectSolidMainFrameMaterials(page);
|
||||
await page.screenshot({ path: "test-results/palette/workbench-desktop-satellite.png", animations: "disabled" });
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-satellite.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
@@ -81,7 +84,7 @@ test.describe("Main Frame workbench", () => {
|
||||
expect(await canvas.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
|
||||
await expect(canvas).toHaveCSS("outline-style", "solid");
|
||||
await expect(canvas).toHaveCSS("outline-width", "1px");
|
||||
await expect(canvas).toHaveCSS("outline-color", "rgba(37, 99, 235, 0.34)");
|
||||
await expect(canvas).toHaveCSS("outline-color", await readThemeColor(canvas, "--action-focus-ring"));
|
||||
await expect(canvas).toHaveCSS("outline-offset", "-1px");
|
||||
await page.screenshot({ path: "test-results/agent-history-ui/map-pointer-focus.png" });
|
||||
});
|
||||
@@ -112,6 +115,7 @@ test.describe("Main Frame workbench", () => {
|
||||
.poll(async () => Math.round((await workspace.boundingBox())?.width ?? 0))
|
||||
.toBe(1920);
|
||||
expect((await workspace.boundingBox())!.width).toBeGreaterThan(expandedWorkspace!.width);
|
||||
await page.screenshot({ path: "test-results/palette/workbench-desktop-wide-max-agent.png", animations: "disabled" });
|
||||
await expect(page).toHaveScreenshot("workbench-desktop-wide-max-agent.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
@@ -124,6 +128,7 @@ test.describe("Main Frame workbench", () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openWorkbench(page);
|
||||
|
||||
await page.screenshot({ path: "test-results/palette/workbench-mobile-launcher-light.png", animations: "disabled" });
|
||||
await expect(page).toHaveScreenshot("workbench-mobile-launcher-light.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
@@ -138,6 +143,7 @@ test.describe("Main Frame workbench", () => {
|
||||
const sheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await expect.poll(async () => Math.round((await sheet.boundingBox())?.height ?? 0)).toBe(439);
|
||||
await page.screenshot({ path: "test-results/palette/workbench-mobile-agent-light.png", animations: "disabled" });
|
||||
await expect(page).toHaveScreenshot("workbench-mobile-agent-light.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
@@ -153,6 +159,7 @@ test.describe("Main Frame workbench", () => {
|
||||
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
|
||||
await expect(sheet).toHaveAttribute("data-snap", "half");
|
||||
await expect(page.locator("main")).toHaveAttribute("data-basemap-tone", "satellite");
|
||||
await page.screenshot({ path: "test-results/palette/workbench-mobile-condition-satellite.png", animations: "disabled" });
|
||||
await expect(page).toHaveScreenshot("workbench-mobile-condition-satellite.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.01
|
||||
@@ -308,7 +315,9 @@ async function expectSolidMainFrameMaterials(page: Page) {
|
||||
};
|
||||
});
|
||||
expect(materials.agentBackdrop).toBe("none");
|
||||
expect(materials.agentBackground).toBe("oklch(0.972 0.005 250)");
|
||||
expect(materials.agentBackground).toBe(
|
||||
await readThemeColor(page.getByRole("complementary", { name: "Agent 命令面板" }), "--agent-dock-surface")
|
||||
);
|
||||
expect(materials.agentShadow).toBe("none");
|
||||
}
|
||||
|
||||
@@ -321,35 +330,6 @@ async function expectLoadedChineseFont(page: Page) {
|
||||
}
|
||||
|
||||
async function expectBodyContrast(page: Page) {
|
||||
const contrast = await page
|
||||
.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。")
|
||||
.evaluate((element) => {
|
||||
const foreground = parseColor(getComputedStyle(element).color);
|
||||
const background = { r: 250, g: 252, b: 254 };
|
||||
return contrastRatio(foreground, background);
|
||||
|
||||
function parseColor(color: string) {
|
||||
const channels = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [0, 0, 0];
|
||||
return { r: channels[0] ?? 0, g: channels[1] ?? 0, b: channels[2] ?? 0 };
|
||||
}
|
||||
|
||||
function contrastRatio(
|
||||
first: { r: number; g: number; b: number },
|
||||
second: { r: number; g: number; b: number }
|
||||
) {
|
||||
const lighter = Math.max(luminance(first), luminance(second));
|
||||
const darker = Math.min(luminance(first), luminance(second));
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function luminance(color: { r: number; g: number; b: number }) {
|
||||
const [r, g, b] = [color.r, color.g, color.b].map((channel) => {
|
||||
const normalized = channel / 255;
|
||||
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
|
||||
}
|
||||
});
|
||||
|
||||
expect(contrast).toBeGreaterThanOrEqual(4.5);
|
||||
const body = page.getByText("直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。");
|
||||
expect(await readRenderedContrast(body)).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user