Files
meshcore-analyzer/public/gesture-hints.js
T
116efe4bd7 fix(#1402): gesture hints — edge-drawer mobile-only + row-swipe widening (re-fix) (#1586)
Partial fix for #1402

## Summary
Re-fix two of the four #1402 regressions on mobile after `#1452`
silently reverted the prior fix (`6ec08acb`). Two predicate flips in
`public/gesture-hints.js` + extended E2E coverage to prevent another
silent revert.

This PR is intentionally **scoped to Bug 2 and Bug 4 only**. Bug 1 and
Bug 3 were also dropped by `#1452` and are NOT restored here — `#1402`
remains open for the rest.

## Changes
- `public/gesture-hints.js` (edge-drawer): `window.innerWidth > 768` →
`window.innerWidth <= 768`. The edge-swipe drawer is the MOBILE layout's
nav per #1064/#1184; `nav-drawer.js` `NARROW_MAX=768` (inclusive —
narrow when width <= NARROW_MAX). Above 768 the sidebar is persistent,
no edge-swipe is needed.
- `public/gesture-hints.js` (row-swipe): widen route filter from
`/^#\/(packets|nodes)/` to `/^#\/(packets|nodes|channels|observers)/`.
Channels and observers also render swipable row tables.
- `public/gesture-hints.js`: expose read-only
`window.__gestureHintsDefs` test hook (frozen) for direct predicate
probes (avoids race with render path).
- `test-gesture-hints-1065-e2e.js`: add assertions (i)+(j) at vw=393 —
edge-drawer relevant on `/#/home`, row-swipe relevant on `/#/channels`;
(k) negative-direction gate at vw=1024 asserts `edge-drawer.relevant()
=== false` on desktop. Retarget (e) from 1024x800 → 393x800 to match the
corrected mobile-only gate.

## TDD
- Red commit: `1e7545d1` — test additions fail against current
production code (edge-drawer relevant returns false at vw=393, row-swipe
filter rejects /channels).
- Green commit: `6f844d5b` — predicate flips + route widening make both
assertions pass.
- Polish commit (round-1 fixes): boundary <= 768, doc-header refresh,
freeze the test hook, negative-direction gate (k), precondition
assertion on (i).

## Acceptance criteria from #1402
- [ ] Bug 1 (`window 'load'` rescheduler + `pointer: coarse` gate) —
dropped by #1452, NOT restored in this PR. Tracked in #1402.
- [x] Bug 2 (edge-drawer mobile-only) — fixed here.
- [ ] Bug 3 (pull-refresh touch-gate decoupling) — dropped by #1452, NOT
restored in this PR. Tracked in #1402.
- [x] Bug 4 (row-swipe widening → /channels + /observers) — fixed here.
- [x] E2E mutation gate: assertions (i)+(j)+(k) provably fail if either
predicate is reverted or re-broadened.

## Notes
- Silently reverted by #1452 — re-fix here, with regression gates so the
next reviewer of the next refactor will see the assertions fail rather
than the production behavior change unnoticed.

## Preflight
All gates pass (PII, branch scope, red commit, CSS vars, XSS sinks,
etc.).

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: fix-1166-bot <bot@corescope.local>
2026-06-04 16:41:32 -07:00

255 lines
8.8 KiB
JavaScript

/* gesture-hints.js — Issue #1065
* First-visit gesture discoverability hints.
*
* - localStorage namespace: meshcore-gesture-hints-<hint>
* keys: row-swipe, tab-swipe, edge-drawer, pull-refresh
* value: "seen"
* - Show hint 800ms after page settle; auto-fade 8s; "Got it" dismisses.
* - aria-live=polite, role=status, no focus stealing, pointer-events:none.
* - prefers-reduced-motion: animation-name: none (style.css handles via media query).
* - Singleton + cleanup: module-scoped guard; SPA re-mount must not re-show dismissed.
* - Pull-to-refresh hint only when .pull-to-reconnect element exists in DOM.
* - Edge-drawer hint only at viewport <= 768px (mobile layout, where the
* edge-swipe drawer is the nav UI; nav-drawer.js NARROW_MAX=768, inclusive).
* - Row-swipe hint only on table pages: /#/packets, /#/nodes, etc.
*/
(function () {
'use strict';
if (window.__gestureHints1065Init) {
window.__gestureHints1065Init++;
return;
}
window.__gestureHints1065Init = 1;
var NS = 'meshcore-gesture-hints-';
// #1244: gesture hints are bottom-anchored pills. On /live they get
// buried below the absolute-positioned VCR bar (+ safe-area inset),
// appearing as orphan "Got it" litter visible only after scrolling.
// Option (a) from #1244 — disable hints on /live entirely. Swipe-nav
// discoverability doesn't apply on Live anyway (map drag, VCR
// controls, and feed all own touch).
function onLiveRoute() {
var h = location.hash || '';
return /^#\/live(\/|$|\?)/.test(h);
}
// #1065 follow-up: hints must only appear on touch-capable viewports.
// Mouse-only desktops (e.g. analyzer.00id.net opened in Chrome on a
// workstation) were getting "swipe a row left" tips that make no sense.
// Three independent probes — any positive answer counts.
function hasTouchCapability() {
try {
if ('ontouchstart' in window) return true;
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) return true;
if (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) return true;
} catch (_e) {}
return false;
}
var HINTS = {
'row-swipe': {
key: NS + 'row-swipe',
text: 'Tip: swipe a row left for quick actions.',
relevant: function () {
if (!hasTouchCapability()) return false;
if (onLiveRoute()) return false; // #1244
var h = location.hash || '';
return /^#\/(packets|nodes|channels|observers)/.test(h);
},
position: 'bottom',
},
'tab-swipe': {
key: NS + 'tab-swipe',
text: 'Tip: swipe left or right to switch tabs.',
relevant: function () {
if (!hasTouchCapability()) return false;
if (onLiveRoute()) return false; // #1244
return !!document.querySelector('[data-bottom-nav]');
},
position: 'bottom',
},
'edge-drawer': {
key: NS + 'edge-drawer',
text: 'Tip: swipe in from the left edge to open navigation.',
relevant: function () {
if (!hasTouchCapability()) return false;
if (onLiveRoute()) return false; // #1244
// nav-drawer.js: NARROW_MAX=768; "narrow" is inclusive — narrow when
// width <= NARROW_MAX (nav-drawer treats width > NARROW_MAX as the
// non-narrow / sidebar layout). The edge-swipe drawer is the MOBILE
// (≤768) layout's nav UI per #1064/#1184. Above 768, the persistent
// sidebar is visible and no edge-swipe is needed.
return window.innerWidth <= 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]');
},
position: 'top-left',
},
'pull-refresh': {
key: NS + 'pull-refresh',
text: 'Tip: pull down to refresh the connection.',
relevant: function () {
if (!hasTouchCapability()) return false;
if (onLiveRoute()) return false; // #1244
return !!document.querySelector('.pull-to-reconnect');
},
position: 'top',
},
};
var SHOW_DELAY_MS = 800;
var AUTO_FADE_MS = 8000;
var _shown = Object.create(null); // hint id → element (currently rendered)
var _scheduledTimer = null;
var _routeChangeBound = false;
function isSeen(id) {
try { return localStorage.getItem(HINTS[id].key) === 'seen'; }
catch (_e) { return false; }
}
function markSeen(id) {
try { localStorage.setItem(HINTS[id].key, 'seen'); } catch (_e) {}
}
function clearAll() {
try {
Object.keys(HINTS).forEach(function (id) { localStorage.removeItem(HINTS[id].key); });
} catch (_e) {}
}
function buildHintEl(id) {
var def = HINTS[id];
var wrap = document.createElement('div');
wrap.className = 'gesture-hint gesture-hint-' + def.position;
// Belt-and-suspenders: inline style guarantees pointer-events:none
// regardless of CSS load order or cascade collisions. The hint must
// never capture clicks; only the inner button does (via .gesture-hint-inner).
wrap.style.pointerEvents = 'none';
wrap.setAttribute('data-gesture-hint', id);
wrap.setAttribute('role', 'status');
wrap.setAttribute('aria-live', 'polite');
wrap.setAttribute('aria-atomic', 'true');
var inner = document.createElement('div');
inner.className = 'gesture-hint-inner';
var msg = document.createElement('span');
msg.className = 'gesture-hint-text';
msg.textContent = def.text;
inner.appendChild(msg);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'gesture-hint-dismiss';
btn.setAttribute('data-gesture-hint-dismiss', '');
btn.setAttribute('aria-label', 'Dismiss hint');
btn.textContent = 'Got it';
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
dismiss(id);
});
inner.appendChild(btn);
wrap.appendChild(inner);
return wrap;
}
function show(id) {
if (_shown[id]) return;
if (isSeen(id)) return;
var def = HINTS[id];
if (!def || !def.relevant()) return;
var el = buildHintEl(id);
document.body.appendChild(el);
_shown[id] = el;
// Auto-fade after AUTO_FADE_MS — does NOT mark seen; user must explicitly dismiss
// (per AC: "Got it" button clears the flag).
var fadeTimer = setTimeout(function () {
if (_shown[id] === el) {
el.classList.add('gesture-hint-fading');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
if (_shown[id] === el) delete _shown[id];
}, 350);
}
}, AUTO_FADE_MS);
el._gestureHintFadeTimer = fadeTimer;
}
function dismiss(id) {
var el = _shown[id];
markSeen(id);
if (el) {
if (el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
if (el.parentNode) el.parentNode.removeChild(el);
delete _shown[id];
}
}
function scheduleHints() {
if (_scheduledTimer) clearTimeout(_scheduledTimer);
_scheduledTimer = setTimeout(function () {
_scheduledTimer = null;
Object.keys(HINTS).forEach(function (id) {
if (!isSeen(id)) show(id);
});
}, SHOW_DELAY_MS);
}
function onRouteChange() {
// Remove hints that are no longer relevant for the new route.
Object.keys(_shown).slice().forEach(function (id) {
var def = HINTS[id];
if (!def || !def.relevant()) {
var el = _shown[id];
if (el && el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
if (el && el.parentNode) el.parentNode.removeChild(el);
delete _shown[id];
}
});
// Re-evaluate: show any not-yet-seen relevant hints.
scheduleHints();
}
function init() {
if (!_routeChangeBound) {
_routeChangeBound = true;
window.addEventListener('hashchange', onRouteChange);
}
scheduleHints();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
// #1402 test hook: expose hint definitions for E2E predicate probes.
// Read-only by convention; tests call .relevant() to verify routing/viewport gates.
window.__gestureHintsDefs = HINTS;
// M3: freeze the hint defs to prevent tests / page scripts from mutating
// production state via the test hook. Shallow-freeze HINTS + each def.
try {
Object.keys(HINTS).forEach(function (id) { Object.freeze(HINTS[id]); });
Object.freeze(HINTS);
} catch (_e) {}
window.GestureHints = {
show: show,
dismiss: dismiss,
reset: function () {
clearAll();
// Remove any visible.
Object.keys(_shown).slice().forEach(function (id) {
var el = _shown[id];
if (el && el._gestureHintFadeTimer) clearTimeout(el._gestureHintFadeTimer);
if (el && el.parentNode) el.parentNode.removeChild(el);
delete _shown[id];
});
},
_keys: function () {
return Object.keys(HINTS).map(function (id) { return HINTS[id].key; });
},
};
})();