Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ jobs:
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gesture-hints-1065-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-charts-fluid-1058-e2e.js 2>&1 | tee -a e2e-output.txt
Expand Down
16 changes: 16 additions & 0 deletions public/customize-v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,9 @@
'<option value="km"' + (distUnit === 'km' ? ' selected' : '') + '>Kilometers (km)</option>' +
'<option value="mi"' + (distUnit === 'mi' ? ' selected' : '') + '>Miles (mi)</option>' +
'</select></div>' +
'<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Gesture Hints</p>' +
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
'</div>';
}

Expand Down Expand Up @@ -1609,6 +1612,19 @@
_runPipeline();
_renderPanel(container);
});

// Reset gesture hints (#1065)
var hintsBtn = container.querySelector('[data-cv2-reset-hints]');
if (hintsBtn) hintsBtn.addEventListener('click', function () {
if (window.GestureHints && typeof window.GestureHints.reset === 'function') {
window.GestureHints.reset();
} else {
// Fallback: clear known keys directly.
['row-swipe', 'tab-swipe', 'edge-drawer', 'pull-refresh'].forEach(function (k) {
try { localStorage.removeItem('meshcore-gesture-hints-' + k); } catch (_e) {}
});
}
});
}

// ── Panel toggle ──
Expand Down
208 changes: 208 additions & 0 deletions public/gesture-hints.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/* 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 (where edge-swipe drawer applies).
* - 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-';
var HINTS = {
'row-swipe': {
key: NS + 'row-swipe',
text: 'Tip: swipe a row left for quick actions.',
relevant: function () {
var h = location.hash || '';
return /^#\/(packets|nodes|live)/.test(h);
},
position: 'bottom',
},
'tab-swipe': {
key: NS + 'tab-swipe',
text: 'Tip: swipe left or right to switch tabs.',
relevant: function () {
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 () {
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 () {
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();
}

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; });
},
};
})();
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
<script src="bottom-nav.js?v=__BUST__"></script>
<script src="nav-drawer.js?v=__BUST__"></script>
<script src="touch-gestures.js?v=__BUST__"></script>
<script src="gesture-hints.js?v=__BUST__"></script>
<script src="url-state.js?v=__BUST__"></script>
<script src="home.js?v=__BUST__"></script>
<script src="table-sort.js?v=__BUST__"></script>
Expand Down
80 changes: 80 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3554,3 +3554,83 @@ body { touch-action: pan-y; }
.row-action-overlay { transition: none !important; }
}
/* === end #1062 ====================================================== */ (feat(#1062): green — implement gesture system)

/* === Issue #1065 — Gesture discoverability hints =================== */
.gesture-hint {
position: fixed;
z-index: 9999;
max-width: 360px;
/* !important guards against any layered cascade enabling pointer-events
* (e.g., a future overlay class with higher specificity). The hint
* wrapper MUST never capture clicks — only its inner button does. */
pointer-events: none !important;
opacity: 1;
animation-name: gesture-hint-slide-in;
animation-duration: 240ms;
animation-timing-function: ease-out;
animation-fill-mode: both;
transition: opacity 320ms ease-out;
}
.gesture-hint-bottom {
left: 50%;
bottom: 80px;
transform: translateX(-50%);
}
.gesture-hint-top {
left: 50%;
top: 16px;
transform: translateX(-50%);
}
.gesture-hint-top-left {
left: 16px;
top: 80px;
}
.gesture-hint-inner {
pointer-events: auto;
display: flex;
align-items: center;
gap: 12px;
background: var(--surface-2, #1a1a1a);
color: var(--text, #e7e7e7);
border: 1px solid var(--border, #333);
border-radius: 999px;
padding: 8px 8px 8px 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
font-size: 13px;
line-height: 1.3;
}
.gesture-hint-text {
white-space: normal;
}
.gesture-hint-dismiss {
pointer-events: auto;
background: var(--accent, #4a9eff);
color: var(--accent-fg, #fff);
border: none;
border-radius: 999px;
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
flex: 0 0 auto;
}
.gesture-hint-dismiss:hover { filter: brightness(1.1); }
.gesture-hint-fading { opacity: 0; }

@keyframes gesture-hint-slide-in {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.gesture-hint-top-left { animation-name: gesture-hint-fade-in; }
@keyframes gesture-hint-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
.gesture-hint {
animation-name: none !important;
animation-duration: 0s !important;
}
}
/* === end #1065 ====================================================== */
Loading