feat(ui): enhance layout and styling for config and topbar components

- Updated grid layout for the config layout to allow full-width usage.
- Introduced new styles for top tabs and search components to improve usability.
- Added theme mode toggle styling for better visual integration.
- Implemented tests for layout and theme mode components to ensure proper rendering and functionality.
This commit is contained in:
Val Alexander
2026-03-10 22:46:54 -05:00
parent 2150c6c8e2
commit 6cac87ccae
8 changed files with 408 additions and 27 deletions

View File

@@ -5,7 +5,7 @@
/* Layout Container */
.config-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: 0;
height: calc(100vh - 160px);
margin: 0 -16px -32px; /* preserve margin-top: 0 for onboarding mode */
@@ -436,6 +436,78 @@
color: var(--muted);
}
.config-top-tabs {
display: grid;
grid-template-columns: minmax(220px, 320px) minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 12px 22px;
background: var(--bg-accent);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
:root[data-theme="light"] .config-top-tabs {
background: var(--bg-hover);
}
.config-search--top {
padding: 0;
border-bottom: none;
min-width: 0;
}
.config-top-tabs__scroller {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
}
.config-top-tabs__tab {
flex: 0 0 auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 8px 12px;
background: var(--bg-elevated);
color: var(--muted);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
}
:root[data-theme="light"] .config-top-tabs__tab {
background: white;
}
.config-top-tabs__tab:hover {
color: var(--text);
border-color: var(--border-strong);
background: var(--bg-hover);
}
.config-top-tabs__tab.active {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: var(--accent-subtle);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 18%, transparent);
}
.config-top-tabs__right {
display: flex;
justify-content: flex-end;
min-width: 0;
}
/* Diff Panel */
.config-diff {
margin: 18px 22px 0;
@@ -624,6 +696,7 @@
flex: 1;
overflow-y: auto;
padding: 22px;
min-width: 0;
}
.config-raw-field textarea {
@@ -687,9 +760,12 @@
.config-form--modern {
display: grid;
gap: 20px;
width: 100%;
min-width: 0;
}
.config-section-card {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
@@ -754,6 +830,7 @@
.config-section-card__content {
padding: 18px;
min-width: 0;
}
/* ===========================================
@@ -1542,10 +1619,6 @@
=========================================== */
@media (max-width: 768px) {
.config-layout {
grid-template-columns: 1fr;
}
.config-sidebar {
border-right: none;
border-bottom: 1px solid var(--border);
@@ -1589,6 +1662,24 @@
justify-content: center;
}
.config-top-tabs {
grid-template-columns: 1fr;
align-items: stretch;
padding: 12px 16px;
}
.config-top-tabs__right {
justify-content: stretch;
}
.config-top-tabs__right .config-mode-toggle {
width: 100%;
}
.config-top-tabs__right .config-mode-toggle__btn {
flex: 1 1 50%;
}
.config-section-hero {
padding: 14px 16px;
}

View File

@@ -185,6 +185,64 @@
font-size: 13px;
}
.topbar-theme-mode {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: calc(var(--radius-lg) + 2px);
background: color-mix(in srgb, var(--bg-elevated) 82%, transparent);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 10%, transparent);
}
.topbar-theme-mode__btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease,
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
}
.topbar-theme-mode__btn:hover {
color: var(--text);
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
}
.topbar-theme-mode__btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.topbar-theme-mode__btn--active {
color: var(--accent);
background: var(--accent-subtle);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
box-shadow: inset 0 1px 0 color-mix(in srgb, white 14%, transparent);
}
.topbar-theme-mode__btn svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.75px;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ===========================================
Navigation Sidebar
=========================================== */

View File

@@ -425,7 +425,8 @@ export function renderApp(state: AppViewState) {
`
}
<button
class="sidebar-collapse-btn"
type="button"
class="nav-collapse-toggle"
@click=${() =>
state.applySettings({
...state.settings,
@@ -434,7 +435,7 @@ export function renderApp(state: AppViewState) {
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
>
${state.settings.navCollapsed ? icons.panelLeftOpen : icons.panelLeftClose}
<span class="nav-collapse-toggle__icon" aria-hidden="true">${icons.menu}</span>
</button>
</div>

View File

@@ -0,0 +1,98 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderConfig, resetConfigViewStateForTests } from "./views/config.ts";
function baseProps() {
resetConfigViewStateForTests();
return {
raw: "{\n}\n",
originalRaw: "{\n}\n",
valid: true,
issues: [],
loading: false,
saving: false,
applying: false,
updating: false,
connected: true,
schema: {
type: "object",
properties: {
gateway: { type: "object", properties: {} },
communication: {
type: "object",
properties: {
webhookBaseUrl: {
type: "string",
title: "Webhook Base URL",
},
},
},
},
},
schemaLoading: false,
uiHints: {},
formMode: "form" as const,
showModeToggle: true,
formValue: {},
originalValue: {},
searchQuery: "",
activeSection: "communication",
activeSubsection: null,
streamMode: false,
onRawChange: vi.fn(),
onFormModeChange: vi.fn(),
onFormPatch: vi.fn(),
onSearchChange: vi.fn(),
onSectionChange: vi.fn(),
onReload: vi.fn(),
onSave: vi.fn(),
onApply: vi.fn(),
onUpdate: vi.fn(),
onSubsectionChange: vi.fn(),
version: "",
theme: "claw" as const,
themeMode: "system" as const,
setTheme: vi.fn(),
setThemeMode: vi.fn(),
gatewayUrl: "",
assistantName: "",
};
}
describe("config layout width", () => {
it("lets the main config pane span the available width instead of collapsing to a dead sidebar track", () => {
const host = document.createElement("div");
host.style.width = "1200px";
document.body.append(host);
render(renderConfig(baseProps()), host);
const layout = host.querySelector<HTMLElement>(".config-layout");
const main = host.querySelector<HTMLElement>(".config-main");
const card = host.querySelector<HTMLElement>(".config-section-card");
expect(layout).not.toBeNull();
expect(main).not.toBeNull();
expect(card).not.toBeNull();
expect(getComputedStyle(layout!).display).toBe("grid");
expect(main!.getBoundingClientRect().width).toBeGreaterThan(800);
expect(card!.getBoundingClientRect().width).toBeGreaterThan(800);
});
it("lays out the search, tabs, and mode toggle as a real full-width top rail", () => {
const host = document.createElement("div");
host.style.width = "1200px";
document.body.append(host);
render(renderConfig(baseProps()), host);
const topTabs = host.querySelector<HTMLElement>(".config-top-tabs");
const scroller = host.querySelector<HTMLElement>(".config-top-tabs__scroller");
expect(topTabs).not.toBeNull();
expect(scroller).not.toBeNull();
expect(getComputedStyle(topTabs!).display).toBe("grid");
expect(getComputedStyle(scroller!).display).toBe("flex");
});
});

View File

@@ -45,4 +45,23 @@ describe("icon layout styling", () => {
expect(getComputedStyle(svg!).width).toBe("14px");
expect(getComputedStyle(svg!).height).toBe("14px");
});
it("renders the shared nav collapse trigger with the compact hamburger icon", () => {
const container = document.createElement("div");
document.body.append(container);
render(
html`<button class="nav-collapse-toggle" type="button">
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>`,
container,
);
const button = container.querySelector<HTMLElement>(".nav-collapse-toggle");
const svg = container.querySelector<SVGElement>(".nav-collapse-toggle__icon svg");
expect(button).not.toBeNull();
expect(svg).not.toBeNull();
expect(getComputedStyle(button!).display).toBe("flex");
expect(getComputedStyle(svg!).width).toBe("18px");
expect(getComputedStyle(svg!).height).toBe("18px");
});
});

View File

@@ -58,6 +58,33 @@ function setControlUiBasePath(value: string | undefined) {
});
}
function setViteDevScript(enabled: boolean) {
if (
typeof document === "undefined" ||
typeof (document as Partial<Document>).querySelectorAll !== "function" ||
typeof (document as Partial<Document>).createElement !== "function"
) {
vi.stubGlobal("document", {
querySelector: (selector: string) =>
enabled && selector.includes("/@vite/client") ? ({} as Element) : null,
querySelectorAll: () => [],
createElement: () => ({ setAttribute() {}, remove() {} }) as unknown as HTMLScriptElement,
head: { append() {} },
} as Document);
return;
}
document
.querySelectorAll('script[data-test-vite-client="true"]')
.forEach((node) => node.remove());
if (!enabled) {
return;
}
const script = document.createElement("script");
script.setAttribute("data-test-vite-client", "true");
script.setAttribute("src", "/@vite/client");
document.head.append(script);
}
function expectedGatewayUrl(basePath: string): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}${basePath}`;
@@ -90,11 +117,13 @@ describe("loadSettings default gateway URL derivation", () => {
localStorage.clear();
sessionStorage.clear();
setControlUiBasePath(undefined);
setViteDevScript(false);
});
afterEach(() => {
vi.restoreAllMocks();
setControlUiBasePath(undefined);
setViteDevScript(false);
vi.unstubAllGlobals();
});
@@ -121,6 +150,44 @@ describe("loadSettings default gateway URL derivation", () => {
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw"));
});
it("defaults vite dev pages to the local gateway port", async () => {
setTestLocation({
protocol: "http:",
host: "127.0.0.1:5174",
pathname: "/chat",
});
setViteDevScript(true);
const { loadSettings } = await import("./storage.ts");
expect(loadSettings().gatewayUrl).toBe("ws://127.0.0.1:18789");
});
it("migrates persisted vite dev gateway URLs to the local gateway port", async () => {
setTestLocation({
protocol: "http:",
host: "127.0.0.1:5174",
pathname: "/chat",
});
setViteDevScript(true);
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl: "ws://127.0.0.1:5174",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
}),
);
const { loadSettings } = await import("./storage.ts");
expect(loadSettings().gatewayUrl).toBe("ws://127.0.0.1:18789");
});
it("ignores and scrubs legacy persisted tokens", async () => {
setTestLocation({
protocol: "https:",

View File

@@ -24,6 +24,35 @@ export type UiSettings = {
locale?: string;
};
function isViteDevPage(): boolean {
if (typeof document === "undefined") {
return false;
}
return Boolean(document.querySelector('script[src*="/@vite/client"]'));
}
function formatHostWithPort(hostname: string, port: string): string {
const normalizedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
return `${normalizedHost}:${port}`;
}
function deriveDefaultGatewayUrl(): { pageUrl: string; effectiveUrl: string } {
const proto = location.protocol === "https:" ? "wss" : "ws";
const configured =
typeof window !== "undefined" &&
typeof window.__OPENCLAW_CONTROL_UI_BASE_PATH__ === "string" &&
window.__OPENCLAW_CONTROL_UI_BASE_PATH__.trim();
const basePath = configured
? normalizeBasePath(configured)
: inferBasePathFromPathname(location.pathname);
const pageUrl = `${proto}://${location.host}${basePath}`;
if (!isViteDevPage()) {
return { pageUrl, effectiveUrl: pageUrl };
}
const effectiveUrl = `${proto}://${formatHostWithPort(location.hostname, "18789")}`;
return { pageUrl, effectiveUrl };
}
function getSessionStorage(): Storage | null {
if (typeof window !== "undefined" && window.sessionStorage) {
return window.sessionStorage;
@@ -91,17 +120,7 @@ function persistSessionToken(gatewayUrl: string, token: string) {
}
export function loadSettings(): UiSettings {
const defaultUrl = (() => {
const proto = location.protocol === "https:" ? "wss" : "ws";
const configured =
typeof window !== "undefined" &&
typeof window.__OPENCLAW_CONTROL_UI_BASE_PATH__ === "string" &&
window.__OPENCLAW_CONTROL_UI_BASE_PATH__.trim();
const basePath = configured
? normalizeBasePath(configured)
: inferBasePathFromPathname(location.pathname);
return `${proto}://${location.host}${basePath}`;
})();
const { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl();
const defaults: UiSettings = {
gatewayUrl: defaultUrl,
@@ -124,21 +143,19 @@ export function loadSettings(): UiSettings {
return defaults;
}
const parsed = JSON.parse(raw) as Partial<UiSettings>;
const parsedGatewayUrl =
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl;
const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl;
const { theme, mode } = parseThemeSelection(
(parsed as { theme?: unknown }).theme,
(parsed as { themeMode?: unknown }).themeMode,
);
const settings = {
gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
gatewayUrl,
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: loadSessionToken(
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
),
token: loadSessionToken(gatewayUrl),
sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()

View File

@@ -0,0 +1,30 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderTopbarThemeModeToggle } from "./app-render.helpers.ts";
describe("topbar theme mode styling", () => {
it("renders icon-sized mode buttons instead of unstyled fallback boxes", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderTopbarThemeModeToggle({
themeMode: "system",
setThemeMode: () => {},
} as never),
container,
);
const group = container.querySelector<HTMLElement>(".topbar-theme-mode");
const button = container.querySelector<HTMLElement>(".topbar-theme-mode__btn");
const svg = container.querySelector<SVGElement>(".topbar-theme-mode__btn svg");
expect(group).not.toBeNull();
expect(button).not.toBeNull();
expect(svg).not.toBeNull();
expect(getComputedStyle(group!).display).toBe("inline-flex");
expect(getComputedStyle(button!).display).toBe("flex");
expect(getComputedStyle(svg!).width).toBe("14px");
expect(getComputedStyle(svg!).height).toBe("14px");
});
});