mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(ui): align sidebar trigger affordances
Align the Control UI and exported transcript sidebar triggers around a shared accessible hamburger affordance.
This commit is contained in:
@@ -969,25 +969,67 @@ body {
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
#hamburger {
|
||||
#hamburger.sidebar-menu-trigger {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 100;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
background: var(--body-bg);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--dim);
|
||||
border-radius: 3px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease,
|
||||
color 120ms ease,
|
||||
transform 120ms ease;
|
||||
box-shadow:
|
||||
0 8px 20px rgba(0, 0, 0, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
#hamburger:hover {
|
||||
#hamburger.sidebar-menu-trigger:hover {
|
||||
background: var(--container-bg);
|
||||
color: var(--text);
|
||||
border-color: var(--text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#hamburger.sidebar-menu-trigger:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#hamburger.sidebar-menu-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--body-bg),
|
||||
0 0 0 3px var(--accent);
|
||||
}
|
||||
|
||||
#hamburger.sidebar-menu-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#hamburger.sidebar-menu-trigger {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#hamburger.sidebar-menu-trigger:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
#sidebar-overlay {
|
||||
@@ -1022,7 +1064,7 @@ body {
|
||||
}
|
||||
|
||||
#hamburger {
|
||||
display: block;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
<style data-openclaw-export-placeholder="CSS"></style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="hamburger" title="Open sidebar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<circle cx="6" cy="6" r="2.5" />
|
||||
<circle cx="6" cy="18" r="2.5" />
|
||||
<circle cx="18" cy="12" r="2.5" />
|
||||
<rect x="5" y="6" width="2" height="12" />
|
||||
<path d="M6 12h10c1 0 2 0 2-2V8" />
|
||||
<button id="hamburger" class="sidebar-menu-trigger" title="Open sidebar" aria-label="Open sidebar">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<line x1="4" x2="20" y1="6" y2="6" />
|
||||
<line x1="4" x2="20" y1="12" y2="12" />
|
||||
<line x1="4" x2="20" y1="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="sidebar-overlay"></div>
|
||||
|
||||
@@ -42,6 +42,7 @@ const LINKEDOM_MODULE = "linkedom";
|
||||
|
||||
const exportHtmlDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const templateHtml = fs.readFileSync(path.join(exportHtmlDir, "template.html"), "utf8");
|
||||
const templateCss = fs.readFileSync(path.join(exportHtmlDir, "template.css"), "utf8");
|
||||
const templateJs = fs.readFileSync(path.join(exportHtmlDir, "template.js"), "utf8");
|
||||
const markedJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "marked.min.js"), "utf8");
|
||||
const highlightJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "highlight.min.js"), "utf8");
|
||||
@@ -140,6 +141,21 @@ function now() {
|
||||
return new Date("2026-02-24T00:00:00.000Z").toISOString();
|
||||
}
|
||||
|
||||
describe("export html sidebar trigger affordance", () => {
|
||||
it("keeps the hamburger sidebar trigger accessible and visibly interactive", () => {
|
||||
expect(templateHtml).toContain('id="hamburger" class="sidebar-menu-trigger"');
|
||||
expect(templateHtml).toContain('aria-label="Open sidebar"');
|
||||
expect(templateHtml).toContain('<line x1="4" x2="20" y1="6" y2="6" />');
|
||||
expect(templateHtml).toContain('<line x1="4" x2="20" y1="12" y2="12" />');
|
||||
expect(templateHtml).toContain('<line x1="4" x2="20" y1="18" y2="18" />');
|
||||
expect(templateCss).toContain("#hamburger.sidebar-menu-trigger {");
|
||||
expect(templateCss).toContain("cursor: pointer;");
|
||||
expect(templateCss).toContain("#hamburger.sidebar-menu-trigger:hover {");
|
||||
expect(templateCss).toContain("background: var(--container-bg);");
|
||||
expect(templateCss).toContain("#hamburger.sidebar-menu-trigger:focus-visible {");
|
||||
});
|
||||
});
|
||||
|
||||
describe("export html security hardening", () => {
|
||||
it("escapes raw HTML from markdown blocks", async () => {
|
||||
const attack = "<img src=x onerror=alert(1)>";
|
||||
|
||||
@@ -106,6 +106,42 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sidebar-menu-trigger {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 80%, transparent);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease,
|
||||
transform var(--duration-fast) ease;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-menu-trigger:hover {
|
||||
border-color: color-mix(in srgb, var(--border-strong) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
|
||||
color: var(--text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sidebar-menu-trigger:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sidebar-menu-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.topbar-nav-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -126,16 +126,6 @@
|
||||
/* Show the hamburger toggle at the same breakpoint where the drawer takes over. */
|
||||
.topbar-nav-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 80%, transparent);
|
||||
color: var(--muted);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
@@ -309,17 +299,6 @@
|
||||
|
||||
.topbar-nav-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 80%, transparent);
|
||||
color: var(--muted);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent);
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
|
||||
@@ -11,6 +11,15 @@ function readMobileCss(): string {
|
||||
return readFileSync(cssPath!, "utf8");
|
||||
}
|
||||
|
||||
function readLayoutCss(): string {
|
||||
const cssPath = [
|
||||
resolve(process.cwd(), "ui/src/styles/layout.css"),
|
||||
resolve(process.cwd(), "..", "ui/src/styles/layout.css"),
|
||||
].find((candidate) => existsSync(candidate));
|
||||
expect(cssPath).toBeTruthy();
|
||||
return readFileSync(cssPath!, "utf8");
|
||||
}
|
||||
|
||||
describe("chat header responsive mobile styles", () => {
|
||||
it("keeps the chat header and session controls from clipping on narrow widths", () => {
|
||||
const css = readMobileCss();
|
||||
@@ -21,3 +30,19 @@ describe("chat header responsive mobile styles", () => {
|
||||
expect(css).toContain(".chat-controls__thinking-select");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar menu trigger styles", () => {
|
||||
it("keeps the mobile sidebar trigger visibly interactive on hover and keyboard focus", () => {
|
||||
const css = readLayoutCss();
|
||||
|
||||
expect(css).toContain(".sidebar-menu-trigger {");
|
||||
expect(css).toContain("cursor: pointer;");
|
||||
expect(css).toContain(".sidebar-menu-trigger:hover {");
|
||||
expect(css).toContain("background: color-mix(in srgb, var(--bg-hover) 84%, transparent);");
|
||||
expect(css).toContain("color: var(--text);");
|
||||
expect(css).toContain(".sidebar-menu-trigger:focus-visible {");
|
||||
expect(css).toContain("box-shadow: var(--focus-ring);");
|
||||
expect(css).toContain(".topbar-nav-toggle {");
|
||||
expect(css).toContain("display: none;");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1341,7 +1341,7 @@ export function renderApp(state: AppViewState) {
|
||||
<div class="topnav-shell">
|
||||
<button
|
||||
type="button"
|
||||
class="topbar-nav-toggle"
|
||||
class="sidebar-menu-trigger topbar-nav-toggle"
|
||||
@click=${() => {
|
||||
state.navDrawerOpen = !navDrawerOpen;
|
||||
}}
|
||||
|
||||
@@ -393,6 +393,7 @@ describe("control UI routing", () => {
|
||||
}
|
||||
|
||||
expect(toggle.classList.contains("topbar-nav-toggle")).toBe(true);
|
||||
expect(toggle.classList.contains("sidebar-menu-trigger")).toBe(true);
|
||||
expect(actions.classList.contains("topnav-shell__actions")).toBe(true);
|
||||
expect(topShell.firstElementChild).toBe(toggle);
|
||||
expect(topShell.querySelector(".topbar-nav-toggle")).toBe(toggle);
|
||||
|
||||
Reference in New Issue
Block a user