diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts
new file mode 100644
index 000000000..694ec5c31
--- /dev/null
+++ b/frontend/src/__tests__/mobile-nav.test.ts
@@ -0,0 +1,153 @@
+/**
+ * Mobile navigation drawer tests
+ *
+ * Covers: hamburger open/close, overlay click, Escape key, sidebar link click,
+ * aria-expanded toggle, focus management, body scroll-lock class.
+ */
+
+import { setupMobileNav } from '../app';
+
+function buildDOM(): void {
+ document.body.innerHTML = `
+
+
+
+ `;
+}
+
+describe('setupMobileNav', () => {
+ beforeEach(() => {
+ buildDOM();
+ document.body.classList.remove('sidebar-open');
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ document.body.classList.remove('sidebar-open');
+ });
+
+ function hamburger(): HTMLButtonElement {
+ return document.getElementById('hamburger-btn') as HTMLButtonElement;
+ }
+ function sidebar(): HTMLElement {
+ return document.getElementById('sidebar') as HTMLElement;
+ }
+ function overlay(): HTMLElement {
+ return document.getElementById('sidebar-overlay') as HTMLElement;
+ }
+
+ test('hamburger click adds sidebar-open to body', () => {
+ setupMobileNav();
+ hamburger().click();
+ expect(document.body.classList.contains('sidebar-open')).toBe(true);
+ });
+
+ test('hamburger click sets aria-expanded to true on open', () => {
+ setupMobileNav();
+ hamburger().click();
+ expect(hamburger().getAttribute('aria-expanded')).toBe('true');
+ });
+
+ test('second hamburger click removes sidebar-open from body', () => {
+ setupMobileNav();
+ hamburger().click(); // open
+ hamburger().click(); // close
+ expect(document.body.classList.contains('sidebar-open')).toBe(false);
+ });
+
+ test('second hamburger click sets aria-expanded to false', () => {
+ setupMobileNav();
+ hamburger().click(); // open
+ hamburger().click(); // close
+ expect(hamburger().getAttribute('aria-expanded')).toBe('false');
+ });
+
+ test('overlay click closes the drawer', () => {
+ setupMobileNav();
+ hamburger().click(); // open
+ overlay().click(); // close via overlay
+ expect(document.body.classList.contains('sidebar-open')).toBe(false);
+ });
+
+ test('overlay click sets aria-expanded to false', () => {
+ setupMobileNav();
+ hamburger().click();
+ overlay().click();
+ expect(hamburger().getAttribute('aria-expanded')).toBe('false');
+ });
+
+ test('Escape key closes the drawer when open', () => {
+ setupMobileNav();
+ hamburger().click(); // open
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ expect(document.body.classList.contains('sidebar-open')).toBe(false);
+ });
+
+ test('Escape key does not throw when drawer is already closed', () => {
+ setupMobileNav();
+ expect(() => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ }).not.toThrow();
+ expect(document.body.classList.contains('sidebar-open')).toBe(false);
+ });
+
+ test('clicking a sidebar link closes the drawer', () => {
+ setupMobileNav();
+ hamburger().click(); // open
+ const firstLink = sidebar().querySelector('.tab-btn');
+ firstLink!.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
+ expect(document.body.classList.contains('sidebar-open')).toBe(false);
+ });
+
+ test('modifier-key click on sidebar link does not close the drawer', () => {
+ setupMobileNav();
+ hamburger().click(); // open
+ const firstLink = sidebar().querySelector('.tab-btn');
+ // Ctrl+click simulates "open in new tab" — should not close the drawer
+ firstLink!.dispatchEvent(new MouseEvent('click', { button: 0, ctrlKey: true, bubbles: true }));
+ expect(document.body.classList.contains('sidebar-open')).toBe(true);
+ });
+
+ test('sidebar aria-hidden is false when open, true when closed', () => {
+ setupMobileNav();
+ hamburger().click(); // open
+ expect(sidebar().getAttribute('aria-hidden')).toBe('false');
+ hamburger().click(); // close
+ expect(sidebar().getAttribute('aria-hidden')).toBe('true');
+ });
+
+ test('overlay aria-hidden is false when open, true when closed', () => {
+ setupMobileNav();
+ hamburger().click(); // open
+ expect(overlay().getAttribute('aria-hidden')).toBe('false');
+ overlay().click(); // close
+ expect(overlay().getAttribute('aria-hidden')).toBe('true');
+ });
+
+ test('focus moves to first sidebar link on open', () => {
+ setupMobileNav();
+ hamburger().click();
+ const firstLink = sidebar().querySelector('.tab-btn');
+ // jsdom doesn't implement layout so focus() is a no-op, but we can
+ // verify the call was attempted by checking that no error was thrown
+ // and the structure is correct
+ expect(firstLink).not.toBeNull();
+ });
+
+ test('no-op when hamburger element is missing from DOM', () => {
+ document.getElementById('hamburger-btn')!.remove();
+ expect(() => setupMobileNav()).not.toThrow();
+ });
+
+ test('no-op when sidebar element is missing from DOM', () => {
+ document.getElementById('sidebar')!.remove();
+ expect(() => setupMobileNav()).not.toThrow();
+ });
+});
diff --git a/frontend/src/app.ts b/frontend/src/app.ts
index 213b82a17..e40f2c5ad 100644
--- a/frontend/src/app.ts
+++ b/frontend/src/app.ts
@@ -191,6 +191,9 @@ export function setupEventListeners(): void {
// Setup feedback link
setupFeedbackLink();
+ // Mobile navigation drawer (hamburger button below 768px)
+ setupMobileNav();
+
// Setup all button event listeners (replacing onclick handlers)
setupButtonHandlers();
@@ -624,6 +627,92 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise {
}
}
+/**
+ * Wire the mobile navigation drawer (hamburger button + overlay + Escape).
+ *
+ * Activates below 768px (CSS hides the hamburger above that breakpoint).
+ *
+ * Open: body.classList.add('sidebar-open')
+ * hamburger aria-expanded="true"
+ * sidebar aria-hidden="false"
+ * Focus first focusable link in the sidebar
+ *
+ * Close: body.classList.remove('sidebar-open')
+ * hamburger aria-expanded="false"
+ * sidebar aria-hidden="true"
+ * Return focus to the hamburger button
+ *
+ * Sidebar links also close the drawer (they navigate within the SPA).
+ */
+export function setupMobileNav(): void {
+ const hamburger = document.getElementById('hamburger-btn') as HTMLButtonElement | null;
+ const sidebar = document.getElementById('sidebar') as HTMLElement | null;
+ const overlay = document.getElementById('sidebar-overlay') as HTMLElement | null;
+ if (!hamburger || !sidebar) return;
+
+ function openDrawer(): void {
+ document.body.classList.add('sidebar-open');
+ hamburger!.setAttribute('aria-expanded', 'true');
+ sidebar!.setAttribute('aria-hidden', 'false');
+ if (overlay) overlay.setAttribute('aria-hidden', 'false');
+
+ // Focus the first focusable element in the sidebar
+ const firstFocusable = sidebar!.querySelector(
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ );
+ firstFocusable?.focus();
+ }
+
+ function closeDrawer(): void {
+ document.body.classList.remove('sidebar-open');
+ hamburger!.setAttribute('aria-expanded', 'false');
+ sidebar!.setAttribute('aria-hidden', 'true');
+ if (overlay) overlay.setAttribute('aria-hidden', 'true');
+ hamburger!.focus();
+ }
+
+ hamburger.addEventListener('click', () => {
+ const isOpen = document.body.classList.contains('sidebar-open');
+ if (isOpen) {
+ closeDrawer();
+ } else {
+ openDrawer();
+ }
+ });
+
+ // Overlay click closes the drawer
+ if (overlay) {
+ overlay.addEventListener('click', () => closeDrawer());
+ }
+
+ // Escape key closes the drawer
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
+ e.preventDefault();
+ closeDrawer();
+ }
+ });
+
+ // Clicking any sidebar link closes the drawer (SPA navigation)
+ sidebar.querySelectorAll('.tab-btn').forEach(link => {
+ link.addEventListener('click', (e: MouseEvent) => {
+ // Only close on unmodified left-click (same guard as tab switching above)
+ if (
+ e.button !== 0 ||
+ e.metaKey ||
+ e.ctrlKey ||
+ e.shiftKey ||
+ e.altKey
+ ) {
+ return;
+ }
+ if (document.body.classList.contains('sidebar-open')) {
+ closeDrawer();
+ }
+ });
+ });
+}
+
/**
* Setup feedback mailto link with template
*/
diff --git a/frontend/src/index.html b/frontend/src/index.html
index 36b825be9..3948b2be6 100644
--- a/frontend/src/index.html
+++ b/frontend/src/index.html
@@ -23,6 +23,15 @@
Skip to main content
+
+
@@ -38,8 +47,11 @@ CUDly
+
+
-