mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08: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 */
|
/* Mobile */
|
||||||
#hamburger {
|
#hamburger.sidebar-menu-trigger {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
padding: 3px 8px;
|
align-items: center;
|
||||||
font-size: 12px;
|
justify-content: center;
|
||||||
font-family: inherit;
|
width: 38px;
|
||||||
background: transparent;
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--body-bg);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border: 1px solid var(--dim);
|
border: 1px solid var(--dim);
|
||||||
border-radius: 3px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
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);
|
color: var(--text);
|
||||||
border-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 {
|
#sidebar-overlay {
|
||||||
@@ -1022,7 +1064,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#hamburger {
|
#hamburger {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-close {
|
.sidebar-close {
|
||||||
|
|||||||
@@ -7,13 +7,11 @@
|
|||||||
<style data-openclaw-export-placeholder="CSS"></style>
|
<style data-openclaw-export-placeholder="CSS"></style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<button id="hamburger" title="Open sidebar">
|
<button id="hamburger" class="sidebar-menu-trigger" title="Open sidebar" aria-label="Open sidebar">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle cx="6" cy="6" r="2.5" />
|
<line x1="4" x2="20" y1="6" y2="6" />
|
||||||
<circle cx="6" cy="18" r="2.5" />
|
<line x1="4" x2="20" y1="12" y2="12" />
|
||||||
<circle cx="18" cy="12" r="2.5" />
|
<line x1="4" x2="20" y1="18" y2="18" />
|
||||||
<rect x="5" y="6" width="2" height="12" />
|
|
||||||
<path d="M6 12h10c1 0 2 0 2-2V8" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="sidebar-overlay"></div>
|
<div id="sidebar-overlay"></div>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const LINKEDOM_MODULE = "linkedom";
|
|||||||
|
|
||||||
const exportHtmlDir = path.dirname(fileURLToPath(import.meta.url));
|
const exportHtmlDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const templateHtml = fs.readFileSync(path.join(exportHtmlDir, "template.html"), "utf8");
|
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 templateJs = fs.readFileSync(path.join(exportHtmlDir, "template.js"), "utf8");
|
||||||
const markedJs = fs.readFileSync(path.join(exportHtmlDir, "vendor", "marked.min.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");
|
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();
|
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", () => {
|
describe("export html security hardening", () => {
|
||||||
it("escapes raw HTML from markdown blocks", async () => {
|
it("escapes raw HTML from markdown blocks", async () => {
|
||||||
const attack = "<img src=x onerror=alert(1)>";
|
const attack = "<img src=x onerror=alert(1)>";
|
||||||
|
|||||||
@@ -106,6 +106,42 @@
|
|||||||
box-shadow: none;
|
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 {
|
.topbar-nav-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,16 +126,6 @@
|
|||||||
/* Show the hamburger toggle at the same breakpoint where the drawer takes over. */
|
/* Show the hamburger toggle at the same breakpoint where the drawer takes over. */
|
||||||
.topbar-nav-toggle {
|
.topbar-nav-toggle {
|
||||||
display: inline-flex;
|
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,
|
.sidebar,
|
||||||
@@ -309,17 +299,6 @@
|
|||||||
|
|
||||||
.topbar-nav-toggle {
|
.topbar-nav-toggle {
|
||||||
display: inline-flex;
|
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 {
|
.topbar-status {
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ function readMobileCss(): string {
|
|||||||
return readFileSync(cssPath!, "utf8");
|
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", () => {
|
describe("chat header responsive mobile styles", () => {
|
||||||
it("keeps the chat header and session controls from clipping on narrow widths", () => {
|
it("keeps the chat header and session controls from clipping on narrow widths", () => {
|
||||||
const css = readMobileCss();
|
const css = readMobileCss();
|
||||||
@@ -21,3 +30,19 @@ describe("chat header responsive mobile styles", () => {
|
|||||||
expect(css).toContain(".chat-controls__thinking-select");
|
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">
|
<div class="topnav-shell">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="topbar-nav-toggle"
|
class="sidebar-menu-trigger topbar-nav-toggle"
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
state.navDrawerOpen = !navDrawerOpen;
|
state.navDrawerOpen = !navDrawerOpen;
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ describe("control UI routing", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(toggle.classList.contains("topbar-nav-toggle")).toBe(true);
|
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(actions.classList.contains("topnav-shell__actions")).toBe(true);
|
||||||
expect(topShell.firstElementChild).toBe(toggle);
|
expect(topShell.firstElementChild).toBe(toggle);
|
||||||
expect(topShell.querySelector(".topbar-nav-toggle")).toBe(toggle);
|
expect(topShell.querySelector(".topbar-nav-toggle")).toBe(toggle);
|
||||||
|
|||||||
Reference in New Issue
Block a user