`;
}
+function hamburgerBtn(): HTMLButtonElement {
+ return document.getElementById('hamburger-btn') as HTMLButtonElement;
+}
+
describe('setupMobileNav', () => {
beforeEach(() => {
buildDOM();
@@ -151,3 +167,80 @@ describe('setupMobileNav', () => {
expect(() => setupMobileNav()).not.toThrow();
});
});
+
+/**
+ * Placement of the topbar controls (issue #1779).
+ *
+ * These assert DOM parentage only. Whether the relocated controls are
+ * visible, on-screen and large enough to tap is a layout question jsdom
+ * cannot answer -- tests-e2e/mobile-header-drawer.spec.ts measures that in
+ * a real browser at a 390px viewport.
+ */
+describe('syncHeaderPlacement', () => {
+ beforeEach(buildDOM);
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ const projected = (): HTMLElement[] => [
+ document.getElementById('topbar-filters')!,
+ document.getElementById('user-info')!,
+ ];
+
+ function parentIds(): (string | undefined)[] {
+ return projected().map(el => el.parentElement?.id);
+ }
+
+ test('narrow moves the filters and the account actions into the drawer slot', () => {
+ syncHeaderPlacement(true);
+ expect(parentIds()).toEqual(['sidebar-extras', 'sidebar-extras']);
+ });
+
+ test('widening restores both to the topbar, in header order', () => {
+ syncHeaderPlacement(true);
+ syncHeaderPlacement(false);
+
+ const topbar = document.querySelector('.app-topbar')!;
+ expect(projected().every(el => el.parentElement === topbar)).toBe(true);
+ const ids = Array.from(topbar.children).map(el => el.id).filter(Boolean);
+ expect(ids.indexOf('topbar-filters')).toBeLessThan(ids.indexOf('user-info'));
+ });
+
+ test('repeated narrow calls do not re-append the same nodes', () => {
+ syncHeaderPlacement(true);
+ const marker = document.createElement('span');
+ projected()[0]!.appendChild(marker);
+ // A redundant appendChild would tear the subtree out and back in,
+ // dropping focus and closing any open chip popover.
+ syncHeaderPlacement(true);
+ expect(marker.isConnected).toBe(true);
+ expect(document.getElementById('sidebar-extras')!.children).toHaveLength(2);
+ });
+
+ test('no-op when the drawer slot is absent', () => {
+ const topbar = document.querySelector('.app-topbar')!;
+ document.getElementById('sidebar-extras')!.remove();
+ expect(() => syncHeaderPlacement(true)).not.toThrow();
+ expect(projected().every(el => el.parentElement === topbar)).toBe(true);
+ });
+
+ test('clicking an account action in the drawer closes it', () => {
+ setupMobileNav();
+ syncHeaderPlacement(true);
+ hamburgerBtn().click();
+ document.getElementById('logout-btn')!.dispatchEvent(
+ new MouseEvent('click', { button: 0, bubbles: true }),
+ );
+ expect(document.body.classList.contains('sidebar-open')).toBe(false);
+ });
+
+ test('using a filter chip in the drawer leaves it open', () => {
+ setupMobileNav();
+ syncHeaderPlacement(true);
+ hamburgerBtn().click();
+ const chip = document.createElement('button');
+ document.getElementById('topbar-filters')!.appendChild(chip);
+ chip.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
+ expect(document.body.classList.contains('sidebar-open')).toBe(true);
+ });
+});
diff --git a/frontend/src/__tests__/responsive.test.ts b/frontend/src/__tests__/responsive.test.ts
index 9af1e3eff..ea88cd53d 100644
--- a/frontend/src/__tests__/responsive.test.ts
+++ b/frontend/src/__tests__/responsive.test.ts
@@ -41,10 +41,53 @@ describe('responsive.css nav wrap rules', () => {
expect(tabsRule?.[0] ?? '').not.toMatch(/overflow-x/);
});
- it('wraps #user-info inside the ≤768px block so header children do not overflow', () => {
+ /**
+ * On the lazy `([\s\S]*?)\n}` capture used here and below: it does reach
+ * the end of the 768px block, despite looking like it would stop at the
+ * first nested rule. Every rule inside the block is indented, so the
+ * block's own closing brace is the only `}` at column zero and `\n}` can
+ * only anchor there. Measured against a brace-matched extraction: the true
+ * body is 5260 characters, the capture is 5259, the whole difference being
+ * the trailing newline the pattern consumes.
+ *
+ * This holds only while the stylesheet stays formatted that way. A nested
+ * rule closed at column zero would truncate the capture and these
+ * assertions would start passing or failing for the wrong reason.
+ *
+ * Issue #10 wrapped #user-info at ≤768px to stop it overflowing the
+ * header. That never worked: the topbar is a fixed --cudly-topbar-h box,
+ * so the extra rows painted over the page content instead (issue #1779).
+ * The regions are relocated into the drawer now, and the topbar copies
+ * are laid out away rather than left to paint behind the content.
+ *
+ * Source-text assertions only -- whether the relocated controls are
+ * actually visible and tappable is measured in a real browser by
+ * tests-e2e/mobile-header-drawer.spec.ts.
+ */
+ it('takes #user-info and #topbar-filters out of the topbar at ≤768px', () => {
const match = css.match(/@media\s*\(max-width:\s*768px\)\s*{([\s\S]*?)\n}/);
expect(match).not.toBeNull();
const body = match?.[1] ?? '';
- expect(body).toMatch(/#user-info\s*{[\s\S]*?flex-wrap:\s*wrap/);
+ expect(body).toMatch(
+ /\.app-topbar\s*>\s*#topbar-filters,\s*\.app-topbar\s*>\s*#user-info\s*{[\s\S]*?display:\s*none/,
+ );
+ });
+
+ it('styles both regions for the drawer slot at ≤768px', () => {
+ const match = css.match(/@media\s*\(max-width:\s*768px\)\s*{([\s\S]*?)\n}/);
+ expect(match).not.toBeNull();
+ const body = match?.[1] ?? '';
+ expect(body).toMatch(/\.app-sidebar-extras\s*{[\s\S]*?display:\s*flex/);
+ expect(body).toMatch(/\.app-sidebar-extras #topbar-filters/);
+ expect(body).toMatch(/\.app-sidebar-extras #user-info/);
+ });
+
+ it('does not stack the topbar into a column at ≤768px', () => {
+ const match = css.match(/@media\s*\(max-width:\s*768px\)\s*{([\s\S]*?)\n}/);
+ expect(match).not.toBeNull();
+ const body = match?.[1] ?? '';
+ // The `header { flex-direction: column }` rule was the overflow source.
+ expect(body).not.toMatch(/(^|\n)\s*header\s*{/);
+ expect(body).toMatch(/\.app-topbar\s*{[\s\S]*?justify-content:\s*flex-start/);
});
});
diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts
index 329de7248..a916916ca 100644
--- a/frontend/src/__tests__/setup.ts
+++ b/frontend/src/__tests__/setup.ts
@@ -82,6 +82,25 @@ if (typeof globalThis.structuredClone === 'undefined') {
}) as typeof structuredClone;
}
+// jsdom evaluates no CSS media queries and so ships no window.matchMedia,
+// which setupMobileNav() calls to decide where the topbar controls live.
+// Stub it as never-matching: jsdom has no viewport width to honour, so the
+// desktop branch is the only honest default. The narrow branch is asserted
+// by calling syncHeaderPlacement directly, and measured for real in the
+// Playwright suite, which has actual layout.
+if (typeof window.matchMedia !== 'function') {
+ window.matchMedia = ((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(() => false),
+ })) as unknown as typeof window.matchMedia;
+}
+
// Mock alert and confirm
global.alert = jest.fn();
global.confirm = jest.fn(() => true);
diff --git a/frontend/src/app.ts b/frontend/src/app.ts
index bbeb3f183..73bd73f17 100644
--- a/frontend/src/app.ts
+++ b/frontend/src/app.ts
@@ -653,6 +653,45 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise {
}
}
+/**
+ * Drawer breakpoint. Must stay in lockstep with the `max-width: 768px` block
+ * in styles/responsive.css, which lays these same regions out of the topbar.
+ */
+const MOBILE_NAV_QUERY = '(max-width: 768px)';
+
+/**
+ * Topbar regions with no room in the topbar below the breakpoint, listed in
+ * the order they appear in the header so restoring them re-appends correctly.
+ */
+const DRAWER_PROJECTED_IDS = ['topbar-filters', 'user-info'] as const;
+
+/**
+ * Move the global filter chips and the account actions between the topbar
+ * and the drawer (issue #1779). Relocation rather than duplication: the
+ * chips and the logout/profile handlers are bound to these exact nodes, and
+ * moving a node keeps its listeners and state.
+ *
+ * Idempotent, and a no-op when either endpoint is absent.
+ */
+export function syncHeaderPlacement(isNarrow: boolean): void {
+ const topbar = document.querySelector('.app-topbar');
+ const extras = document.getElementById('sidebar-extras');
+ if (!topbar || !extras) return;
+
+ const target = isNarrow ? extras : topbar;
+ const elements = DRAWER_PROJECTED_IDS
+ .map(id => document.getElementById(id))
+ .filter((el): el is HTMLElement => el !== null);
+
+ // Re-append all of them whenever any one is misplaced, so the restored
+ // header keeps DRAWER_PROJECTED_IDS order. Appending an element that is
+ // already in place would tear its subtree out and back in, dropping focus
+ // and closing an open chip popover, so the whole pass is skipped instead.
+ const misplaced = elements.some(el => el.parentElement !== target);
+ if (!misplaced) return;
+ for (const el of elements) target.appendChild(el);
+}
+
/**
* Wire the mobile navigation drawer (hamburger button + overlay + Escape).
*
@@ -660,14 +699,23 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise {
*
* Open: body.classList.add('sidebar-open')
* hamburger aria-expanded="true"
- * sidebar aria-hidden="false"
+ * sidebar aria-hidden="false", inert cleared
* Focus first focusable link in the sidebar
*
* Close: body.classList.remove('sidebar-open')
* hamburger aria-expanded="false"
- * sidebar aria-hidden="true"
+ * sidebar aria-hidden="true", inert set
* Return focus to the hamburger button
*
+ * A closed drawer needs both attributes and they always move together.
+ * aria-hidden takes it out of the accessibility tree; inert takes it out of
+ * sequential keyboard navigation, which neither aria-hidden nor the
+ * off-screen transform affects. With only the first, tabbing walks into
+ * controls that are invisible and expose no accessible name.
+ *
+ * Crossing the breakpoint in either direction is a third transition, neither
+ * an open nor a close: see applyBreakpointState.
+ *
* Sidebar links also close the drawer (they navigate within the SPA).
*/
export function setupMobileNav(): void {
@@ -680,6 +728,9 @@ export function setupMobileNav(): void {
document.body.classList.add('sidebar-open');
hamburger!.setAttribute('aria-expanded', 'true');
sidebar!.setAttribute('aria-hidden', 'false');
+ // Before the focus below, not after: focus into an inert subtree is
+ // dropped silently and the caret would land on .
+ sidebar!.removeAttribute('inert');
if (overlay) overlay.setAttribute('aria-hidden', 'false');
// Focus the first focusable element in the sidebar
@@ -693,10 +744,46 @@ export function setupMobileNav(): void {
document.body.classList.remove('sidebar-open');
hamburger!.setAttribute('aria-expanded', 'false');
sidebar!.setAttribute('aria-hidden', 'true');
+ sidebar!.setAttribute('inert', '');
if (overlay) overlay.setAttribute('aria-hidden', 'true');
hamburger!.focus();
}
+ /**
+ * Put the drawer into the closed state the current breakpoint calls for.
+ * Runs on load and on every crossing, neither of which the user asked for,
+ * so unlike openDrawer and closeDrawer it moves focus only to rescue it.
+ *
+ * Whether the sidebar is reachable at all is what the two widths disagree
+ * about. Above the breakpoint it is the permanently visible navigation and
+ * must stay both in the accessibility tree and in the tab order; below, it
+ * is a drawer parked off-screen holding the controls syncHeaderPlacement
+ * moves into it, and everything in there has to leave both with it.
+ *
+ * The narrow branch can never be hiding an open drawer: the only crossing
+ * that reaches it comes from the desktop side, where nothing opens one.
+ */
+ function applyBreakpointState(isNarrow: boolean): void {
+ // body.sidebar-open sets overflow:hidden outside the media query, so a
+ // drawer open across a resize would lock scrolling with no drawer left.
+ document.body.classList.remove('sidebar-open');
+ hamburger!.setAttribute('aria-expanded', 'false');
+ if (overlay) overlay.setAttribute('aria-hidden', 'true');
+
+ if (!isNarrow) {
+ sidebar!.removeAttribute('aria-hidden');
+ sidebar!.removeAttribute('inert');
+ return;
+ }
+ // Focus survives the crossing when it was already on a sidebar link, and
+ // hiding the subtree under it strands the caret where no screen reader
+ // can follow. Chromium does not blur it for us when inert lands, so this
+ // has to run first.
+ if (sidebar!.contains(document.activeElement)) hamburger!.focus();
+ sidebar!.setAttribute('aria-hidden', 'true');
+ sidebar!.setAttribute('inert', '');
+ }
+
hamburger.addEventListener('click', () => {
const isOpen = document.body.classList.contains('sidebar-open');
if (isOpen) {
@@ -719,6 +806,27 @@ export function setupMobileNav(): void {
}
});
+ // Projected topbar controls: place them for the current width, then follow
+ // the breakpoint. Listeners are bound once here, never per relocation.
+ const mediaQuery = window.matchMedia(MOBILE_NAV_QUERY);
+ syncHeaderPlacement(mediaQuery.matches);
+ applyBreakpointState(mediaQuery.matches);
+ mediaQuery.addEventListener('change', event => {
+ syncHeaderPlacement(event.matches);
+ applyBreakpointState(event.matches);
+ });
+
+ // Account actions inside the drawer navigate or end the session, so they
+ // close it. Delegated from the slot, which outlives every relocation; the
+ // filter chips are deliberately excluded so picking a filter keeps the
+ // drawer open for the next one.
+ const extras = document.getElementById('sidebar-extras');
+ extras?.addEventListener('click', (e: MouseEvent) => {
+ const target = e.target as HTMLElement | null;
+ if (!target?.closest('#user-info a, #user-info button')) return;
+ if (document.body.classList.contains('sidebar-open')) closeDrawer();
+ });
+
// Clicking any sidebar link closes the drawer (SPA navigation)
sidebar.querySelectorAll('.tab-btn').forEach(link => {
link.addEventListener('click', (e: MouseEvent) => {
diff --git a/frontend/src/index.html b/frontend/src/index.html
index 1c659dacd..cb019881a 100644
--- a/frontend/src/index.html
+++ b/frontend/src/index.html
@@ -87,6 +87,13 @@