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:
Val Alexander
2026-04-29 14:33:39 -05:00
committed by GitHub
parent f55b810412
commit 323985f4ca
8 changed files with 134 additions and 37 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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)>";

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;");
});
});

View File

@@ -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;
}}

View File

@@ -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);