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): + * -