fix(ui): match settings controls to scene styling (#107109)

* fix(ui): match settings controls to scene styling

* chore: leave release notes to release automation
This commit is contained in:
Peter Steinberger
2026-07-13 21:34:48 -07:00
committed by GitHub
parent 80399ed19b
commit d45dd28df6
4 changed files with 163 additions and 31 deletions

View File

@@ -0,0 +1,142 @@
// Control UI tests cover shared Settings control styling through the mocked Gateway.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
let browser: Browser;
let server: ControlUiE2eServer;
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"settings-controls",
);
async function resolvedBackground(page: Page, value: string): Promise<string> {
return page.evaluate((background) => {
const probe = document.createElement("div");
probe.style.background = background;
document.body.append(probe);
const resolved = getComputedStyle(probe).backgroundColor;
probe.remove();
return resolved;
}, value);
}
describeControlUiE2e("Control UI Settings controls mocked Gateway E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("keeps the view selector compact and checked switches on the scene accent", async () => {
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const config = { browser: { enabled: true } };
await installMockGateway(page, {
methodResponses: {
"config.get": {
config,
hash: "hash-1",
issues: [],
raw: JSON.stringify(config),
valid: true,
},
},
});
try {
const response = await page.goto(`${server.baseUrl}config`);
expect(response?.status()).toBe(200);
const viewSelector = page
.locator(".content-header")
.getByRole("radiogroup", { name: "Settings view", exact: true });
await viewSelector.waitFor();
const activeMode = page.locator('.content-header wa-radio[value="quick"]');
expect(
await activeMode.evaluate((element) => element.getBoundingClientRect().height),
).toBeLessThanOrEqual(40);
expect(
await page.locator('.content-header [slot="label"]').evaluate((element) => {
const box = element.getBoundingClientRect();
return {
height: box.height,
overflow: getComputedStyle(element).overflow,
width: box.width,
};
}),
).toEqual({ height: 1, overflow: "hidden", width: 1 });
expect(
await activeMode.evaluate((element) => getComputedStyle(element).backgroundColor),
).toBe(await resolvedBackground(page, "var(--bg-elevated)"));
const browserSwitchRole = page
.locator("#settings-general-security")
.getByRole("switch", { name: "Browser enabled", exact: true });
await browserSwitchRole.waitFor();
expect(await browserSwitchRole.getAttribute("aria-checked")).toBe("true");
const browserSwitch = page.locator("#settings-general-security wa-switch.settings-toggle");
expect(
await browserSwitch.evaluate((element) => {
const control = element.shadowRoot?.querySelector<HTMLElement>('[part="control"]');
return control ? getComputedStyle(control).backgroundColor : null;
}),
).toBe(await resolvedBackground(page, "var(--accent)"));
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await page.locator(".content-header").screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, "01-settings-view.png"),
});
await page.locator("#settings-general-security").screenshot({
animations: "disabled",
path: path.join(uiProofArtifactDir, "02-security-controls.png"),
});
}
await browserSwitch.click();
await expect.poll(() => browserSwitchRole.getAttribute("aria-checked")).toBe("false");
await page.locator('.content-header wa-radio[value="advanced"]').click();
await expect
.poll(() =>
page
.locator('.content-header wa-radio[value="advanced"]')
.evaluate((element) => (element as HTMLElement & { checked: boolean }).checked),
)
.toBe(true);
} finally {
await context.close();
}
});
});

View File

@@ -57,7 +57,7 @@ describe("supportsSystemInfo", () => {
});
describe("ConfigPage settings mode control", () => {
it("uses a Web Awesome radio group to switch modes", () => {
it("uses the shared settings segmented control to switch modes", () => {
const page = new ConfigPage();
const state = page as unknown as {
pageId: string;
@@ -74,7 +74,9 @@ describe("ConfigPage settings mode control", () => {
container.querySelectorAll<HTMLElement & { checked: boolean }>("wa-radio"),
);
expect(group?.getAttribute("label")).toBe("Settings view");
expect(group?.classList.contains("settings-segmented")).toBe(true);
expect(group?.querySelector('[slot="label"]')?.textContent).toBe("Settings view");
expect(quick?.classList.contains("settings-segmented__btn--active")).toBe(true);
expect(quick?.checked).toBe(true);
expect(advanced?.checked).toBe(false);
if (group) {

View File

@@ -23,6 +23,7 @@ import {
} from "../../app/settings.ts";
import { startThemeTransition } from "../../app/theme-transition.ts";
import { resolveTheme, type ThemeMode, type ThemeName } from "../../app/theme.ts";
import { renderSettingsSegmented } from "../../components/settings-ui.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { i18n, isSupportedLocale, t, type Locale } from "../../i18n/index.ts";
import { isMissingOperatorReadScopeError } from "../../lib/gateway-errors.ts";
@@ -949,36 +950,20 @@ export class ConfigPage extends OpenClawLightDomElement {
if (this.pageId !== "config") {
return nothing;
}
const modes = [
["quick", t("configPage.simple")],
["advanced", t("configPage.advanced")],
] as const;
return html`
<wa-radio-group
class="config-view-toggle qs-segmented"
label=${t("configPage.settingsView")}
.value=${this.settingsMode}
orientation="horizontal"
@change=${(event: Event) => {
const value = (event.currentTarget as HTMLElement & { value?: string }).value;
if (value === "quick" || value === "advanced") {
this.settingsMode = value;
}
}}
>
${modes.map(
([mode, label]) => html`
<wa-radio
class="qs-segmented__btn"
appearance="button"
value=${mode}
.checked=${this.settingsMode === mode}
>
${label}
</wa-radio>
`,
)}
</wa-radio-group>
<div class="config-view-toggle">
${renderSettingsSegmented({
value: this.settingsMode,
options: [
{ value: "quick", label: t("configPage.simple") },
{ value: "advanced", label: t("configPage.advanced") },
],
ariaLabel: t("configPage.settingsView"),
onChange: (mode) => {
this.settingsMode = mode;
},
})}
</div>
`;
}

View File

@@ -335,6 +335,9 @@ select.settings-select:not([multiple]) {
}
wa-switch.settings-toggle {
/* Web Awesome defaults to brand blue; settings controls follow the active scene. */
--wa-form-control-activated-color: var(--accent);
--wa-color-focus: var(--ring);
flex: none;
}