diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 3abfa761..170beadb 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -250,6 +250,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt
+ CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
- name: Collect frontend coverage (parallel)
if: success() && github.event_name == 'push'
diff --git a/public/index.html b/public/index.html
index 905b7918..3a5508d6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -107,6 +107,7 @@
+
diff --git a/public/nav-drawer.js b/public/nav-drawer.js
new file mode 100644
index 00000000..b26ae09c
--- /dev/null
+++ b/public/nav-drawer.js
@@ -0,0 +1,366 @@
+/* nav-drawer.js — Issue #1064 (parent epic #1052)
+ *
+ * Edge-swipe nav drawer. Slide-over from the LEFT edge.
+ *
+ * Design (Option A): drawer is enabled at viewport widths > 768px ONLY.
+ * At ≤768px the bottom-nav has a "More" tab (PR #1174) that surfaces the
+ * same long-tail routes; a left-edge drawer there would compete with it.
+ *
+ * Inputs (Pointer Events only — touch + pen, never mouse):
+ * - pointerdown within the left edge trigger zone [24px, 44px]
+ * (first 24px reserved for iOS Safari back-swipe — Mesh-Op #1184)
+ * - pointermove → drawer translateX follows finger
+ * - pointerup → settle open/closed via velocity
+ * + position threshold
+ *
+ * Singleton + cleanup (mirrors #1180 fix):
+ * - module-scoped `wired` guard so SPA mounts don't re-bind
+ * - document-level pointermove/pointerup listeners registered ONCE
+ * - matchMedia listener registered ONCE
+ * - `window.__navDrawerPointerBindCount` debug seam (E2E asserts ≤ 1)
+ *
+ * Accessibility:
+ * - drawer has `inert` when closed (removed when open) — keyboard +
+ * screen-reader users skip the off-screen tree.
+ * - focus trap: Tab from last focusable wraps to first; Shift+Tab from
+ * first wraps to last.
+ * - Esc closes; backdrop tap closes; tap on a route closes.
+ * - prefers-reduced-motion: instant snap, no transition.
+ *
+ * Public API (also surfaced as `window.__navDrawer` for tests):
+ * open(), close(), toggle(), isOpen()
+ */
+'use strict';
+
+(function () {
+ if (typeof document === 'undefined') return;
+
+ // ── Module-scoped singleton state ───────────────────────────────────────
+ var wired = false;
+ var drawerEl = null;
+ var backdropEl = null;
+ var dragging = false;
+ var startX = 0;
+ var startY = 0;
+ var startT = 0;
+ var lastX = 0;
+ var lastT = 0;
+ var drawerWidth = 0;
+ var pointerActive = false;
+ var narrowMql = null;
+ // Element that had focus before the drawer was opened — restored on close
+ // (same regression class as #1168: closing nav UI must return focus to its
+ // trigger so keyboard users don't get dumped at
).
+ var prevFocus = null;
+
+ // Long-tail routes mirror PR #1174 / bottom-nav.js MORE_ROUTES exactly.
+ // ⚠️ Keep in sync with public/bottom-nav.js MORE_ROUTES.
+ var ROUTES = [
+ { route: 'nodes', hash: '#/nodes', label: 'Nodes', icon: '🖥️' },
+ { route: 'tools', hash: '#/tools', label: 'Tools', icon: '🛠️' },
+ { route: 'observers', hash: '#/observers', label: 'Observers', icon: '👁️' },
+ { route: 'analytics', hash: '#/analytics', label: 'Analytics', icon: '📊' },
+ { route: 'perf', hash: '#/perf', label: 'Perf', icon: '⚡' },
+ { route: 'audio-lab', hash: '#/audio-lab', label: 'Audio Lab', icon: '🎵' },
+ ];
+
+ var EDGE_PX = 44; // pointerdown must start within left N px (drawer trigger zone)
+ var EDGE_MIN_PX = 24; // first N px reserved for iOS Safari back-swipe (do not claim)
+ var NARROW_MAX = 768; // Option A: disabled at ≤ this width
+ var OPEN_THRESHOLD = 0.5; // % of drawer width at which open settles
+ var VELOCITY_OPEN = 0.4; // px/ms — fling-right opens regardless of position
+ var VELOCITY_CLOSE = -0.4; // px/ms — fling-left closes
+
+ function isWide() {
+ // matchMedia is the source of truth; fall back to innerWidth in non-DOM
+ // environments (won't trigger in browser).
+ if (narrowMql && typeof narrowMql.matches === 'boolean') return !narrowMql.matches;
+ return (window.innerWidth || 0) > NARROW_MAX;
+ }
+
+ function prefersReducedMotion() {
+ try {
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ } catch (_e) { return false; }
+ }
+
+ // ── DOM construction (idempotent) ───────────────────────────────────────
+ function buildDom() {
+ if (drawerEl && backdropEl) return;
+
+ backdropEl = document.createElement('div');
+ backdropEl.className = 'nav-drawer-backdrop';
+ backdropEl.setAttribute('data-nav-drawer-backdrop', '');
+ backdropEl.hidden = true;
+ backdropEl.addEventListener('click', function () { close(); });
+
+ drawerEl = document.createElement('aside');
+ drawerEl.className = 'nav-drawer';
+ drawerEl.setAttribute('data-nav-drawer', '');
+ drawerEl.setAttribute('role', 'navigation');
+ drawerEl.setAttribute('aria-label', 'Edge-swipe navigation drawer');
+ drawerEl.setAttribute('aria-hidden', 'true');
+ drawerEl.setAttribute('inert', '');
+ drawerEl.tabIndex = -1;
+
+ var header = document.createElement('div');
+ header.className = 'nav-drawer-header';
+ var title = document.createElement('span');
+ title.className = 'nav-drawer-title';
+ title.textContent = 'Navigate';
+ var closeBtn = document.createElement('button');
+ closeBtn.type = 'button';
+ closeBtn.className = 'nav-drawer-close';
+ closeBtn.setAttribute('aria-label', 'Close navigation drawer');
+ closeBtn.textContent = '×';
+ closeBtn.addEventListener('click', function () { close(); });
+ header.appendChild(title);
+ header.appendChild(closeBtn);
+ drawerEl.appendChild(header);
+
+ var list = document.createElement('nav');
+ list.className = 'nav-drawer-list';
+ ROUTES.forEach(function (r) {
+ var a = document.createElement('a');
+ a.className = 'nav-drawer-item';
+ a.setAttribute('href', r.hash);
+ a.setAttribute('data-nav-drawer-item', r.route);
+ a.setAttribute('data-route', r.route);
+
+ var ic = document.createElement('span');
+ ic.className = 'nav-drawer-icon';
+ ic.setAttribute('aria-hidden', 'true');
+ ic.textContent = r.icon;
+
+ var lb = document.createElement('span');
+ lb.className = 'nav-drawer-label';
+ lb.textContent = r.label;
+
+ a.appendChild(ic);
+ a.appendChild(lb);
+ a.addEventListener('click', function () { close(); });
+ list.appendChild(a);
+ });
+ drawerEl.appendChild(list);
+
+ document.body.appendChild(backdropEl);
+ document.body.appendChild(drawerEl);
+
+ // Defer width measurement until after layout.
+ requestAnimationFrame(function () {
+ drawerWidth = drawerEl.getBoundingClientRect().width || 320;
+ });
+ }
+
+ // ── Open/close primitives ───────────────────────────────────────────────
+ function setTranslate(px) {
+ if (!drawerEl) return;
+ drawerEl.style.transform = 'translateX(' + px + 'px)';
+ }
+
+ function clearInlineTransform() {
+ if (drawerEl) drawerEl.style.transform = '';
+ }
+
+ function isOpen() {
+ return !!(drawerEl && drawerEl.classList.contains('is-open'));
+ }
+
+ function open() {
+ buildDom();
+ if (!isWide()) return; // Option A
+ if (!drawerWidth) drawerWidth = drawerEl.getBoundingClientRect().width || 320;
+ // Capture the previously-focused element BEFORE we move focus, so close()
+ // can restore it. Guard against opening twice (don't overwrite on re-open).
+ if (!isOpen()) {
+ try {
+ var ae = document.activeElement;
+ prevFocus = (ae && ae !== document.body) ? ae : null;
+ } catch (_e) { prevFocus = null; }
+ }
+ drawerEl.classList.add('is-open');
+ drawerEl.removeAttribute('inert');
+ drawerEl.setAttribute('aria-hidden', 'false');
+ backdropEl.hidden = false;
+ backdropEl.classList.add('is-open');
+ clearInlineTransform();
+ // Move focus into the drawer for keyboard users / screen readers.
+ var firstFocusable = drawerEl.querySelector(
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
+ );
+ if (firstFocusable) {
+ try { firstFocusable.focus({ preventScroll: true }); } catch (_e) { firstFocusable.focus(); }
+ }
+ }
+
+ function close() {
+ if (!drawerEl) return;
+ var wasOpen = drawerEl.classList.contains('is-open');
+ // Decide whether to restore focus BEFORE applying `inert`. Setting
+ // `inert` synchronously moves document.activeElement to , so any
+ // "is focus inside the drawer?" check after that point is useless.
+ // The right invariant: restore if we were open, prevFocus is still in
+ // the DOM, and it isn't a descendant of the drawer itself.
+ var toRestore = null;
+ if (wasOpen && prevFocus && typeof prevFocus.focus === 'function' &&
+ document.body && document.body.contains(prevFocus) &&
+ !drawerEl.contains(prevFocus)) {
+ toRestore = prevFocus;
+ }
+ prevFocus = null;
+ // Restore FIRST so the upcoming `inert` doesn't bump us to .
+ if (toRestore) {
+ try { toRestore.focus({ preventScroll: true }); }
+ catch (_e) { /* element may be gone after SPA nav — ignore */ }
+ }
+ drawerEl.classList.remove('is-open');
+ drawerEl.setAttribute('inert', '');
+ drawerEl.setAttribute('aria-hidden', 'true');
+ if (backdropEl) {
+ backdropEl.hidden = true;
+ backdropEl.classList.remove('is-open');
+ }
+ clearInlineTransform();
+ }
+
+ function toggle() { if (isOpen()) close(); else open(); }
+
+ // ── Pointer drag-tracking ───────────────────────────────────────────────
+ function onPointerDown(e) {
+ // Mesh-Op review (PR #1184): only respond to touch + pen. Mouse drags
+ // from the left edge must NOT open the drawer (a stray mouse-down at
+ // x r.right) return;
+ } else {
+ // Drawer trigger zone: [EDGE_MIN_PX, EDGE_PX]. The first EDGE_MIN_PX
+ // are reserved for iOS Safari's system back-swipe gesture (Mesh-Op
+ // review on #1184); claiming x < 24 collides with the OS gesture and
+ // leaves iPad users with a flaky double-fire.
+ if (x < EDGE_MIN_PX) return;
+ if (x > EDGE_PX) return;
+ }
+ buildDom();
+ if (!drawerWidth) drawerWidth = drawerEl.getBoundingClientRect().width || 320;
+ dragging = true;
+ pointerActive = true;
+ startX = lastX = x;
+ startY = e.clientY;
+ startT = lastT = (e.timeStamp || performance.now());
+ }
+
+ function onPointerMove(e) {
+ if (!dragging || !pointerActive) return;
+ var x = e.clientX;
+ var y = e.clientY;
+ // If the gesture is mostly vertical near the start, abandon (let scroll win).
+ if (Math.abs(x - startX) < 8 && Math.abs(y - startY) > 12) {
+ dragging = false;
+ pointerActive = false;
+ clearInlineTransform();
+ return;
+ }
+ lastX = x;
+ lastT = (e.timeStamp || performance.now());
+ if (prefersReducedMotion()) return; // no live tracking — settle on up
+ // Compute drawer x-position based on whether we started open or closed.
+ var basis = isOpen() ? 0 : -drawerWidth;
+ var delta = x - startX;
+ var px = Math.max(-drawerWidth, Math.min(0, basis + delta));
+ setTranslate(px);
+ }
+
+ function onPointerUp(e) {
+ if (!pointerActive) return;
+ pointerActive = false;
+ if (!dragging) { clearInlineTransform(); return; }
+ dragging = false;
+ var x = (e && typeof e.clientX === 'number') ? e.clientX : lastX;
+ var t = (e && e.timeStamp) || performance.now();
+ var dt = Math.max(1, t - startT);
+ var velocity = (x - startX) / dt; // px/ms
+ var openedBefore = isOpen();
+ clearInlineTransform();
+ if (openedBefore) {
+ if (velocity < VELOCITY_CLOSE || (x - startX) < -drawerWidth * OPEN_THRESHOLD) {
+ close();
+ } else {
+ open();
+ }
+ } else {
+ if (velocity > VELOCITY_OPEN || (x - startX) > drawerWidth * OPEN_THRESHOLD) {
+ open();
+ } else {
+ close();
+ }
+ }
+ }
+
+ // ── Focus trap ──────────────────────────────────────────────────────────
+ function onKeydown(e) {
+ if (!isOpen()) return;
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ close();
+ return;
+ }
+ if (e.key !== 'Tab' || !drawerEl) return;
+ var focusables = drawerEl.querySelectorAll(
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
+ );
+ if (focusables.length === 0) return;
+ var first = focusables[0];
+ var last = focusables[focusables.length - 1];
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+
+ // ── Wire-up (called once) ───────────────────────────────────────────────
+ function wireOnce() {
+ if (wired) return;
+ wired = true;
+
+ try { narrowMql = window.matchMedia('(max-width: ' + NARROW_MAX + 'px)'); }
+ catch (_e) { narrowMql = null; }
+
+ document.addEventListener('pointerdown', onPointerDown, { passive: true });
+ document.addEventListener('pointermove', onPointerMove, { passive: true });
+ document.addEventListener('pointerup', onPointerUp, { passive: true });
+ document.addEventListener('pointercancel', onPointerUp, { passive: true });
+ document.addEventListener('keydown', onKeydown);
+
+ // Close drawer if viewport drops to narrow (Option A).
+ if (narrowMql && typeof narrowMql.addEventListener === 'function') {
+ narrowMql.addEventListener('change', function () { if (!isWide()) close(); });
+ }
+
+ // Debug seam — E2E asserts this ≤ 1 across SPA navs (singleton proof).
+ window.__navDrawerPointerBindCount = (window.__navDrawerPointerBindCount || 0) + 1;
+ }
+
+ function init() {
+ wireOnce();
+ buildDom();
+ }
+
+ // Public API for tests + manual triggers (e.g. a hamburger button).
+ window.__navDrawer = { open: open, close: close, toggle: toggle, isOpen: isOpen };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init, { once: true });
+ } else {
+ init();
+ }
+})();
diff --git a/public/style.css b/public/style.css
index 20ec9d47..76f2df37 100644
--- a/public/style.css
+++ b/public/style.css
@@ -3366,3 +3366,139 @@ th.sort-active { color: var(--accent, #60a5fa); }
.fux-saved-save:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); }
td[data-filter-field] { cursor: context-menu; }
+
+/* === Issue #1064 — Edge-swipe nav drawer ============================
+ * Slide-over from the LEFT edge. Z-index sits ABOVE bottom-nav (1200)
+ * but BELOW modal (var(--z-modal) = 9100). Fenced for parallel-PR
+ * coordination with #1062 (gesture handlers / swipe-affordance section).
+ * --------------------------------------------------------------------- */
+
+/* `pan-y` lets vertical scroll work everywhere; the drawer claims
+ * horizontal swipes inside our viewport. iOS browser back-swipe (system
+ * left-edge gesture) still works — it's at the OS layer, above the page.
+ *
+ * Mesh-Op review (PR #1184): scope this to wide viewports only. At
+ * ≤768px the drawer is `display:none` (Option A), so this rule does
+ * nothing useful and would block horizontal panning gestures the future
+ * gesture system (#1185) might want to claim. */
+@media (min-width: 769px) {
+ body { touch-action: pan-y; }
+}
+
+.nav-drawer-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ z-index: 1250; /* above bottom-nav (1200), below modal-backdrop (9000) */
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 200ms ease;
+}
+.nav-drawer-backdrop.is-open {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.nav-drawer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: min(320px, 86vw);
+ max-width: 360px;
+ background: var(--surface);
+ color: var(--text);
+ border-right: 1px solid var(--border);
+ box-shadow: 4px 0 18px rgba(0, 0, 0, 0.35);
+ z-index: 1260; /* above its backdrop, still below modal */
+ transform: translateX(-100%);
+ transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ outline: none;
+}
+.nav-drawer.is-open { transform: translateX(0); }
+
+.nav-drawer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-2, var(--surface));
+}
+.nav-drawer-title {
+ font-weight: 700;
+ font-size: var(--fs-md);
+ color: var(--text);
+ letter-spacing: 0.02em;
+}
+.nav-drawer-close {
+ background: transparent;
+ border: none;
+ color: var(--text);
+ font-size: 22px;
+ line-height: 1;
+ width: 36px;
+ height: 36px;
+ border-radius: 6px;
+ cursor: pointer;
+ touch-action: manipulation;
+}
+.nav-drawer-close:hover,
+.nav-drawer-close:focus-visible {
+ background: var(--bg-hover, rgba(120, 160, 255, 0.12));
+ outline: none;
+}
+
+.nav-drawer-list {
+ display: flex;
+ flex-direction: column;
+ padding: 6px 0;
+ overflow-y: auto;
+ flex: 1;
+}
+.nav-drawer-item {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 18px;
+ color: var(--text);
+ text-decoration: none;
+ font-size: var(--fs-md);
+ min-height: 48px;
+ touch-action: manipulation;
+}
+.nav-drawer-item:hover,
+.nav-drawer-item:focus-visible {
+ background: var(--bg-hover, rgba(120, 160, 255, 0.12));
+ outline: none;
+}
+.nav-drawer-icon {
+ font-size: 18px;
+ line-height: 1;
+ width: 24px;
+ text-align: center;
+ flex: none;
+}
+.nav-drawer-label {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Option A: drawer is wide-only (>768px). At ≤768px the bottom-nav has
+ * the More tab (PR #1174) — drawer is hidden + script bails on open(). */
+@media (max-width: 768px) {
+ .nav-drawer,
+ .nav-drawer-backdrop { display: none !important; }
+}
+
+/* Reduced motion: instant snap, no transition. */
+@media (prefers-reduced-motion: reduce) {
+ .nav-drawer { transition: none; }
+ .nav-drawer-backdrop { transition: none; }
+}
+/* === end #1064 ====================================================== */
diff --git a/test-nav-drawer-1064-e2e.js b/test-nav-drawer-1064-e2e.js
new file mode 100644
index 00000000..45c8ab11
--- /dev/null
+++ b/test-nav-drawer-1064-e2e.js
@@ -0,0 +1,297 @@
+#!/usr/bin/env node
+/**
+ * Issue #1064 — Edge-swipe nav drawer (parent epic #1052).
+ *
+ * Asserts:
+ * (a) at 1024x800: touch pointer-down at x=30, drag to x=220 → drawer opens
+ * (24px iOS back-swipe reservation + 24-44px drawer trigger zone),
+ * drawer.getBoundingClientRect().left === 0
+ * (b) drawer items present (long-tail routes from PR #1174)
+ * (c) tap a drawer item → URL hash changes, drawer closes
+ * (d) Esc closes drawer
+ * (e) backdrop click closes drawer
+ * (f) at 360x800: edge-swipe does NOT open drawer (Option A —
+ * drawer disabled at narrow widths because bottom-nav has More tab)
+ * (g) singleton: navigate away+back 5 times, pointermove bind count ≤ 1
+ * (h) focus trap: open drawer, Tab from last focusable wraps to first
+ *
+ * Stable selectors (consumed by the test):
+ * -