mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-24 18:55:19 +00:00
e395c471ed
Red commit: 58b307228e (CI run pending;
URL added after first workflow run posts).
Fixes #1244
## Sub-issue A — VCR controls still 2 rows on mobile
`public/live.css` mobile `@media (max-width:640px)` block had
`flex-wrap: wrap` plus `.vcr-timeline-container { width:100%; flex:none
}`, which guaranteed a 2-row layout (controls + LCD on row 1, scope
buttons + scrubber on row 2) — the exact bug #1234 was supposed to
eliminate.
Fix: switched `.vcr-bar` to `flex-wrap: nowrap`, gave
`.vcr-timeline-container` `flex: 1 1 0` so it absorbs leftover width,
and shrunk `.vcr-btn` / `.vcr-scope-btn` to a 32px touch target (still
WCAG 2.5.5 AA). Reorder on mobile: controls → scopes → timeline → LCD,
single row. `.vcr-mode` stays hidden on mobile as before (and `.vcr-lcd`
no longer needs `margin-left:auto` because the timeline pushes it right
via flex-grow).
## Sub-issue B — Orphan "Got it" hint pills hidden below the fold
`public/gesture-hints.js` row-swipe relevance included `/live`, and the
pills are bottom-anchored — so they rendered under the
absolute-positioned VCR bar + safe-area inset and were only findable by
scrolling.
Picked **option (a)** from the issue (simplest, matches user's report):
all four hints now early-return on `/#/live*`. Swipe-nav discoverability
doesn't apply on Live — map drag, VCR controls, and feed own the touch
surface.
## TDD
- RED `test-issue-1244-live-vcr-row-hints-e2e.js`: asserts at 375x800
(A) `.vcr-bar` children share a row (≤8px top spread OR
`flex-wrap:nowrap`), (B) zero `.gesture-hint` elements on `/live`.
Desktop sanity asserts LCD/controls still share a row.
- GREEN: the two source fixes.
E2E assertion added: `test-issue-1244-live-vcr-row-hints-e2e.js:67`
(single-row), `:101` (no hints). Wired into
`.github/workflows/deploy.yml` `e2e-test` job.
Browser verified: pending CI on Playwright fixture run (local Playwright
unavailable on this ARM host).
Desktop layout untouched — every mobile rule lives under `@media
(max-width:640px)`; existing #1221 + #1234 desktop assertions still
apply.
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
223 lines
7.1 KiB
JavaScript
223 lines
7.1 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 (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-';
|
|
// #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);
|
|
}
|
|
var HINTS = {
|
|
'row-swipe': {
|
|
key: NS + 'row-swipe',
|
|
text: 'Tip: swipe a row left for quick actions.',
|
|
relevant: function () {
|
|
if (onLiveRoute()) return false; // #1244
|
|
var h = location.hash || '';
|
|
return /^#\/(packets|nodes)/.test(h);
|
|
},
|
|
position: 'bottom',
|
|
},
|
|
'tab-swipe': {
|
|
key: NS + 'tab-swipe',
|
|
text: 'Tip: swipe left or right to switch tabs.',
|
|
relevant: function () {
|
|
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 (onLiveRoute()) return false; // #1244
|
|
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 (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();
|
|
}
|
|
|
|
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; });
|
|
},
|
|
};
|
|
})();
|