fix(#1064): edge-swipe nav drawer (Option A, wide-only)#1184
fix(#1064): edge-swipe nav drawer (Option A, wide-only)#1184Kpa-clawbot merged 8 commits intomasterfrom
Conversation
Adds test-nav-drawer-1064-e2e.js covering: (a) edge-swipe at x=10→200 opens drawer flush at left:0 (b) drawer items mirror PR #1174 long-tail routes (c) tap drawer item navigates + closes (d) Esc closes (e) backdrop click closes (f) narrow viewport (≤768px) does NOT open drawer (Option A) (g) singleton: 5 SPA round-trips keep pointermove bind count ≤ 1 (h) focus trap: Tab from last wraps to first Wires test into deploy.yml Playwright matrix. CI must FAIL on assertion: nav-drawer.js / CSS not implemented yet.
NEW public/nav-drawer.js - Pointer Events only (no touchstart/mousedown fallbacks) - Edge-swipe within left 20px starts drag-track - Drawer translateX follows finger; settle on velocity + position - Singleton: module-scoped wired guard, document listeners bound once - window.__navDrawerPointerBindCount debug seam (E2E asserts ≤ 1) - Focus trap (Tab cycles), Esc closes, backdrop tap closes - inert when closed, removed when open - prefers-reduced-motion: instant snap NEW CSS section in public/style.css (fenced for #1062 parallel safety) - body { touch-action: pan-y } — preserves iOS back-swipe + vertical scroll - .nav-drawer + .nav-drawer-backdrop slide-over from left - z-index 1260/1250 (above bottom-nav 1200, below modal 9100) - @media (max-width: 768px) hides drawer (Option A) public/index.html - Wires nav-drawer.js after bottom-nav.js Closes the implementation half of #1064; CI E2E should now PASS.
The (e) backdrop-click test timed out at `page.click('[data-nav-drawer-backdrop]', { position: { x: 5, y: 5 } })`. Root cause: z-stack occlusion — the drawer (z-index 1260) sits on top of the backdrop (z-index 1250) over the left ~320px of the viewport. Position (5,5) within the backdrop element is OCCLUDED by the drawer, so Playwright's actionability check waits for a click target that never receives the event.
Production behavior is correct: backdrop has `pointer-events: auto` when open and a real click handler that calls close(). The test simply chose a coordinate that the drawer covers.
Fix: click at x=800,y=400 — viewport is 1024×800, drawer ≤360px wide, so the backdrop is the topmost element at that point and the click lands as intended.
No production code changed.
|
✅ Backdrop click fix pushed. Root cause: z-stack occlusion (cause #3 from the brief). The backdrop has Fix: Commit: 21293a6 |
Self-review findings (pr-polish): 1. nav-drawer.js — close() did not restore focus to the element that had focus before open(). Same regression class as #1168 (closing nav UI must return focus to its trigger so keyboard users don't get dumped at <body>). Capture activeElement on open(), restore on close() iff focus is still inside the drawer at close time. 2. test-nav-drawer-1064-e2e.js — backdrop click used hardcoded {x:800,y:400} which assumes the 1024x800 viewport. Brittle to any viewport change. Compute clickX from viewportSize() — clearly outside the drawer's max-width (360px) at any reasonable viewport. 3. test-nav-drawer-1064-e2e.js — added (d2) regression test that locks in the focus-restoration fix: park focus on a sentinel button, open drawer, confirm focus moves into drawer, close, assert focus came back to sentinel. Previously: the fix would silently regress.
Kpa-clawbot
left a comment
There was a problem hiding this comment.
Independent review — PR #1184
REQUEST CHANGES — 1 must-fix
Reviewed gh pr diff only (no checkout). Scope: public/nav-drawer.js, public/style.css, public/index.html, test-nav-drawer-1064-e2e.js, .github/workflows/deploy.yml.
MUST-FIX 1 — close() applies inert before focus-restore, defeating both the restore and test (d2)
public/nav-drawer.js close():
drawerEl.classList.remove('is-open');
drawerEl.setAttribute('inert', ''); // ← (A) applied here
drawerEl.setAttribute('aria-hidden', 'true');
...
if (wasOpen && prevFocus && typeof prevFocus.focus === 'function') {
try {
if (drawerEl.contains(document.activeElement) && document.contains(prevFocus)) {
prevFocus.focus({ preventScroll: true }); // ← (B) guard never passes
}
} catch (_e) {}
}Per HTML spec, when inert becomes set on a subtree containing document.activeElement, browsers synchronously re-run the focusing steps and move focus to <body>. This is implemented natively in Chromium 102+, Firefox 112+, Safari 15.5+ (i.e. every browser the project targets — inert has no polyfill in this repo).
Consequence at line (B): after line (A) runs, document.activeElement === document.body. The guard drawerEl.contains(document.activeElement) becomes drawerEl.contains(document.body), which is false (<body> is the ancestor of the drawer, not a descendant). The restore branch never runs, prevFocus is set to null at the end, and focus stays on <body>.
This is the exact regression class the PR description (and test d2) explicitly claim to prevent (#1168 callback). E2E (d2) will fail in headless Chromium for the same reason — the assertion document.activeElement.id === '__nav_drawer_focus_sentinel' after close() will be false. (CI is currently still pending on Playwright at review time, so this hasn't been observed yet.)
Fix options (pick one):
- Restore focus before applying
inert:drawerEl.classList.remove('is-open'); // restore focus FIRST while activeElement is still inside drawer if (wasOpen && prevFocus && typeof prevFocus.focus === 'function' && drawerEl.contains(document.activeElement) && document.contains(prevFocus)) { try { prevFocus.focus({ preventScroll: true }); } catch (_e) {} } drawerEl.setAttribute('inert', ''); drawerEl.setAttribute('aria-hidden', 'true');
- Or drop the
drawerEl.contains(document.activeElement)half of the guard (sinceinertalready pulled focus out —document.activeElement === document.bodyis the de-facto signal that the drawer "had" focus). Slightly less precise but works.
Option 1 is preferred — it preserves the original intent ("only restore if focus was actually inside the drawer when we closed it") without relying on the inert side-effect.
Out-of-scope (intentionally not flagged as must-fix)
- Test (g) singleton coverage is weaker than advertised. Hash changes don't re-execute the IIFE, so the
wiredguard isn't actually exercised; the count is fixed at 1 fromwireOnce()regardless. Verifying real re-bind resistance would require re-injecting the script. Not blocking — the code'swiredguard is straightforward and correct, the test just doesn't tax it. - Drag-follow + velocity not unit-tested. Acknowledged in the brief, acceptable.
body { touch-action: pan-y }duplication risk with #1185. Identical declarations; CSS cascade is benign — fix at merge time of the second PR.- Z-index parity with #1174 sheet (both 1250). Confirmed safe: the drawer + backdrop are
display: none !importantat≤768pxvia the@media (max-width: 768px)block instyle.css, and #1174's sheet is narrow-only — no temporal overlap. - No
setPointerCaptureon drag. Document-level pointermove/up listeners cover the off-target release path, so capture is not strictly needed here.
Verified
- Pointer Events lifecycle: down/move/up/cancel all wired once via
wireOnce(); no per-mount rebind. wiredsingleton guard: correct module-scoped state,addEventListeneronly called inside theif (wired) returngate.- Focus trap Tab + Shift+Tab: both branches present, early-return on zero focusables, single-focusable degenerates to no-op (correct).
inerttoggling on open/close: applied at the right times (issue is solely the ordering vs focus-restore inclose()).@media (max-width: 768px)gate on drawer + backdrop: correct, matches Option A spec.- PR description: no deferred items beyond cross-PR
touch-actioncoordination already noted.
Setting `inert` on the drawer synchronously moves document.activeElement to <body>, so the previous `drawerEl.contains(document.activeElement)` guard always saw <body>, returned false, and skipped the restore. Reorder close() so we: 1. Decide whether to restore (prevFocus exists, in DOM, not in drawer) 2. Call prevFocus.focus() FIRST 3. Then apply inert + aria-hidden + class changes Also drop the brittle `drawerEl.contains(activeElement)` half of the guard; the simpler invariant 'prevFocus is still a real element outside the drawer' is what actually matters. Surfaced by adversarial review on PR #1184.
✅ Adversarial finding addressed: focus-restore vs
|
Kpa-clawbot
left a comment
There was a problem hiding this comment.
Mesh-Operator Review
REQUEST CHANGES — 3 must-fix
I deploy ~15 nodes across rooftops + solar sites, run a regional mesh of ~40 users, and check CoreScope from a laptop at home, a Surface tablet in the truck, and an iPad mini on the roof. Reviewing this drawer through that lens: the idea (long-tail routes one swipe away on touch hardware) is fine, but the implementation has three operator-visible failure modes that will bite within a week of shipping.
M1 (must-fix) — Mouse pointers can open the drawer; design says they shouldn't
The whole justification for Option A in the PR body is "touchscreen laptop or tablet." Desktop mouse users already have the top-nav with every route visible — the drawer is explicitly not for them.
But onPointerDown only filters out non-left mouse buttons:
if (e.pointerType === 'mouse' && e.button !== 0) return;It does not filter out left-clicks. So at any wide viewport, a mouse-down within 20 px of the left edge plus any rightward drag past the threshold opens the drawer. Real-world triggers I will hit at my desk:
- Grabbing the browser scrollbar gutter when the page has overflow on the left side of the content area (less common but happens with dev-tools docked right and the window snapped left).
- Selecting text in a left-aligned table cell that starts at x≤20 (the Packets table left column sits right at the edge on a maximized 1920-wide window).
- Click-dragging on a leaflet map near the left edge —
mousedownat x=10, drag the map right to pan, drawer opens over the map.
The fix is one line: gate the handler to e.pointerType === 'touch' || e.pointerType === 'pen'. That matches the stated persona and removes the false-positive class entirely. If you still want a mouse affordance, that's a hamburger button — not an edge-drag.
M2 (must-fix) — iPad Safari back-swipe DOES collide with this drawer
The PR body asserts: "iOS browser back-swipe (system left-edge gesture) still works — it's at the OS layer, above the page." That's true. The unstated half is: on iPad Safari (which is overwhelmingly >768 px and therefore inside Option A's enabled range), both gestures fire on the same swipe. The OS does its back-nav AND the page sees pointerdown/pointermove. Net result on iPad — which is the single most common tablet I see operators carrying into the field — is one of:
- History exists → Safari navigates back, drawer animates open behind the navigation, user lands on the previous page with a drawer visible. Confusing.
- No history (fresh tab on
/#/packets) → back-swipe is a no-op, drawer opens. Now every "I want to go back" gesture summons the menu.
Option A was supposed to avoid this collision by being wide-only. It doesn't, because iPad is wide. Either:
- Detect iPad/iPadOS UA and disable the edge handler (cheap, narrow), or
- Move the trigger off the absolute edge — start from x≥24 px, leaving a 24 px iOS-reserved zone — or
- Document this as a known limitation in the PR body and the user-facing affordance (so I know to use the close-drawer or the top-nav instead of swiping back).
Pretending the collision doesn't exist is the wrong answer; iPad in the truck is half my mobile usage.
M3 (must-fix) — body { touch-action: pan-y } is global, but Option A is wide-only
body { touch-action: pan-y; }This is set unconditionally — including at viewports where the drawer is display:none and the JS open() early-returns. So on phones, where this PR explicitly does nothing, you've still disabled browser-level horizontal panning on the entire body. That cascades into every child element that doesn't set its own touch-action. Things I rely on at narrow widths:
- Horizontally-scrollable code blocks / wide tables (Packets, Stats) — operators read these on the phone while driving to a site.
- The hop-display strip and any future horizontal-scroll telemetry component.
- Any page that embeds an iframe or leaflet map without its own
touch-actionoverride (leaflet sets its own, so the map proper is fine — adjacent containers may not be).
Either scope the rule to where the drawer actually lives:
@media (min-width: 769px) {
body { touch-action: pan-y; }
}…or move it onto a drawer-owned wrapper element instead of body. As written, a wide-only feature is making a global mobile change with no mobile benefit — exactly the kind of cross-cutting side effect that breaks operators on the device they actually use in the field.
Out-of-scope (file as followups, not blocking this PR)
- The hardcoded
ROUTESarray innav-drawer.jsduplicatesMORE_ROUTESinbottom-nav.jswith only a "⚠️ keep in sync" comment guarding drift. First time someone adds/messagesto the More tab and forgets the drawer, my phone and my Surface will show different long-tail menus and I'll waste 30 seconds wondering which one is right. Single source of truth (export the array from one module) is the obvious fix — out-of-scope here, but file it before the next long-tail route lands. - DOM is built unconditionally even at ≤768 px where the drawer is hidden. Minor; skip unless someone is paying for the bytes.
What I do like
- Singleton + bind-count debug seam (mirrors the #1180 fix). That's the right pattern; CI will catch handler leaks before they ship.
inert+ focus restore + reduced-motion handling — keyboard / a11y is taken seriously, not bolted on.- Velocity threshold + position threshold for settle. That's how a drawer should feel.
- E2E asserts the singleton invariant across 5 SPA round-trips. Good gate.
Fix M1/M2/M3 and this is a solid ship for the touch-tablet persona it's actually written for.
— mesh operator
Add failing assertions for Mesh-Operator review on PR #1184: - (a) updated: edge-swipe must start at x=30 (24px iOS reservation) - (f) narrow viewport: same x=30 baseline - (i) NEW: mouse.down at x=10 must NOT open drawer (pointerdown handler must reject pointerType='mouse') - (j) NEW: touch swipe from x=10 must NOT open drawer (first 24px reserved for iOS Safari back-swipe) These tests fail against current main: - (a): EDGE_PX=20, so x=30 is rejected → drawer doesn't open → assert fails - (i): mouse pointerdown is currently accepted → drawer opens → assert fails - (j): EDGE_PX=20 covers x=10 → drawer opens → assert fails Green commit follows.
Three fixes from Mesh-Operator review on PR #1184: 1. nav-drawer.js: pointerdown handler now filters on pointerType. Only 'touch' and 'pen' open the drawer; 'mouse' is rejected at the top of the handler (before any edge math). Stops a stray mouse-down at the left edge from hijacking clicks on left-side widgets. 2. nav-drawer.js: edge trigger zone narrowed from [0, 20] to [24, 44]. First 24px reserved for iOS Safari's system back-swipe gesture. Drawer activates on the next 20px (24-44px from the left edge), eliminating the iPad double-fire collision. EDGE_PX renamed in intent (still the upper bound), EDGE_MIN_PX added (lower bound). 3. style.css: 'body { touch-action: pan-y }' scoped to @media (min-width: 769px). At narrow widths the drawer is display:none anyway, so the global rule did nothing useful and blocked horizontal panning gestures the future gesture system (#1185) might want. Tests from RED commit (fd629d0) flip to green: - (a) edge-swipe at x=30→220 opens drawer - (i) mouse drag at x=10 does NOT open drawer - (j) touch swipe at x=10 does NOT open drawer (inside iOS reservation) - (f) narrow viewport: same x=30 baseline
Mesh-Operator review — all 3 must-fix addressedFollowed TDD: failing assertions landed first (RED ✅ Item 1 —
|
Kpa-clawbot
left a comment
There was a problem hiding this comment.
Kent Beck Gate: NEEDS-WORK — 1 item
TDD compliance — PASS
Walked origin/master..origin/fix/issue-1064-edge-swipe. Three red→green pairs visible:
- Initial:
a810b1f(RED) →c192367(GREEN). RED CI =failure, with real assertion failures (✗ (a) edge-swipe ... [data-nav-drawer] not in DOM,✗ (e) backdrop ... [data-nav-drawer-backdrop] missing,✗ (g) ... debug seam not exposed). True RED on assertions, not build errors. ✅ - Backdrop coord
21293a6: pure test-only fix (z-stack occlusion in test, not prod bug). No production code touched — exemption stands. ✅ - Focus restore
ecfa215(fix + d2 regression test, CI green) →6242c0c(inert-ordering hardening, no test added). The d2 test already covered the behavior;6242c0cis a defensive cleanup of the invariant. Behavior remains covered by d2. Acceptable, but the d2 test is not strengthened to demonstrate the inert-ordering bug independently. ✅ (with note) - Mesh-op review
fd629d0(RED, CI=cancelled — superseded by next push) →29c4783(GREEN, CI=success). Logic-level verification:fd629d0adds assertions forEDGE_MIN_PX=24and pointerType filter;c192367hadEDGE_PX=20with no pointerType gate, so the new (a) and (j) assertions would have asserted-failed against it. Real RED. ✅
Test quality — Six Questions, 1 finding
Anti-tautology, test (i) — mouse-down at left edge does NOT open drawer:
await wide.mouse.move(10, 400);
await wide.mouse.down();
await wide.mouse.move(220, 400, { steps: 12 });
await wide.mouse.up();
// assert drawer NOT openThe implementation has TWO independent gates that reject this drag:
if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;(the gate the test claims to exercise)if (x < EDGE_MIN_PX) return;whereEDGE_MIN_PX = 24(rejects x=10 regardless of pointerType)
Revert the pointerType filter and the test still passes, because x=10 is inside the iOS reservation zone and the edge-zone gate alone rejects the drag. Test (i) does not actually fail when its named guard is removed — that's a tautology by Kent Beck's "show me the test that fails when this change is reverted."
Fix: start the mouse drag at x=30 (inside the [24, 44] trigger zone), so the EDGE_MIN_PX gate would NOT reject it; only the pointerType filter can. Then removing the filter genuinely flips the test.
await wide.mouse.move(30, 400); // inside trigger zone
await wide.mouse.down();
await wide.mouse.move(220, 400, { steps: 12 });
await wide.mouse.up();Other Six Questions — PASS
- Could a wrong impl pass d2? d2 asserts
document.activeElement.id === '__nav_drawer_focus_sentinel'— exact identity, not "anything outside body." A wrong impl that focused<body>or any other element would fail. ✅ - Edge case at x=24 (zone boundary): not directly tested, but
x < EDGE_MIN_PXsemantics make x=24 the first accepted pixel. (a) at x=30 covers the inside; (j) at x=10 covers the outside. Boundary x=24 untested but low-risk given strict inequality. Note, not blocker. - Test names: behavior-driven (
(a) edge-swipe at x=30→220 opens drawer flush at left:0,(d2) close() restores focus to previously-focused element,(j) touch swipe from x=10 ... does NOT open drawer). ✅ - (j) tautology check: uses
pointerType: 'touch', x=10 inside reservation. RemoveEDGE_MIN_PXgate → x=10 inside[0, EDGE_PX]→ drawer opens → test fails. Genuine. ✅
Verdict
NEEDS-WORK — 1 item: re-aim test (i) at x=30 so it actually exercises the pointerType filter rather than the EDGE_MIN_PX zone. Single-line change. Once fixed, this PR is ready to merge.
— Kent Beck (via subagent)
…ointerType filter x=10 falls inside EDGE_MIN_PX=24 (iOS back-swipe reservation), so the edge-zone gate alone already rejected the drag — the pointerType filter was untested. Move x=30 (inside the 24-44 trigger band) so only the pointerType filter can reject. Also bumps end x to 240 so total horizontal movement still exceeds the velocity/distance threshold.
Kpa-clawbot
left a comment
There was a problem hiding this comment.
Kent Beck Gate (cycle 2): PASS
Cycle 1 flagged test (i) as a tautology: mouse.move(10, 400) started inside the iOS back-swipe reservation (x < EDGE_MIN_PX = 24), so the edge-zone gate at nav-drawer.js:247 already rejected the drag before the pointerType filter at :234 had anything to prove. Removing the filter would not have flipped the test → no behavior coverage.
7cc589e moves the start coordinate to x=30 (inside the [EDGE_MIN_PX=24, EDGE_PX=44] trigger band) and bumps the end to x=240 to keep total horizontal travel above the velocity/distance threshold. Now:
- Edge-zone math accepts the drag (24 ≤ 30 ≤ 44).
- The drag fails the pointerdown handler only because
e.pointerType === 'mouse'returns at:234. - Delete that one-line filter and the test fails → assertion gates the production behavior, not a coincidental zone reject.
Six-question check
- Show me the test that fails when this change is reverted. ✅ Revert
:234(if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;) → mouse drag from x=30→240 satisfies trigger zone + distance/velocity → drawer opens → assertion!openfails. - Smallest test that catches the bug. ✅ Single Playwright mouse-drag with start coord inside the validated trigger band; complement test (j) covers the touch+reservation axis at x=10.
- Could a wrong implementation pass? ✅ Not anymore — the only remaining gate between mouse-drag and drawer-open is the
pointerTypefilter under test. - Untested edges. Pen pointerType + edge-zone is not asserted, but spec says pen IS allowed; the rejection-only suite for mouse is what #1064 needs. Acceptable.
- Name describes behavior. ✅
(i) mouse-down at left edge does NOT open drawer (pointerType=mouse must be ignored). - Setup vs. assertion ratio. ✅ Three mouse calls + close-and-wait setup; assertion is one boolean. Clean.
CI
HEAD 7cc589e10994cbcb492c53f7dff736d61ae44686: Go Build & Test ✅, Playwright E2E ✅, Docker ✅. mergeable: MERGEABLE.
Anti-tautology debt cleared. Merge-ready from the TDD axis.
Red commit: a810b1f (CI run pending — draft PR)
Fixes #1064 (parent epic #1052).
What
Edge-swipe nav drawer (slide-over from left). Pointer-down within left 20px → drag-follows-finger animation → settles open/closed on velocity threshold. Drawer surfaces the same long-tail routes as the More sheet (PR #1174) as an alternate entry point at the EDGE.
Decisions
Option A — drawer wide-only (>768px). At ≤768px the bottom-nav has the More tab (PR #1174); a left-edge drawer there would compete with that UX. Wide viewports have no More tab, so the drawer replaces the top-nav hamburger as a faster keyboard-free entry. Bottom-nav coexistence at narrow widths: none — drawer is disabled.
Singleton + cleanup. Module-scoped guard +
__navDrawerPointerBindCountdebug seam — same pattern as #1180 fix.pointermove/pointerupare bound ondocumentexactly once across SPA mounts.body { touch-action: pan-y }. Vertical scroll preserved everywhere; the drawer claims horizontal swipes only inside our viewport. iOS browser-back left-edge gesture still works (it's the OS, not the page).Accessibility.
inerton the drawer when closed, removed when open. Focus trap (Tab cycles last↔first). Esc closes. Backdrop tap closes.prefers-reduced-motion: instant snap, no animation.Tests (TDD)
Red commit pushes the E2E first; CI must FAIL on assertion.
E2E assertion added:
test-nav-drawer-1064-e2e.js:1CSS coordination with #1062
Additions are fenced (
/* === Issue #1064 — Edge-swipe nav drawer === */ … /* === end #1064 === */) to minimize merge friction.