mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 17:21:13 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
=========================================== */
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
98
ui/src/ui/config-layout.browser.test.ts
Normal file
98
ui/src/ui/config-layout.browser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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()
|
||||
|
||||
30
ui/src/ui/topbar-theme-mode.browser.test.ts
Normal file
30
ui/src/ui/topbar-theme-mode.browser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user