diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 5ce86045..74162291 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -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
diff --git a/public/customize-v2.js b/public/customize-v2.js
index 42eee405..3e50d532 100644
--- a/public/customize-v2.js
+++ b/public/customize-v2.js
@@ -1202,6 +1202,9 @@
'' +
'' +
'' +
+ '
Gesture Hints
' +
+ 'Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).
' +
+ '' +
'';
}
@@ -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 ──
diff --git a/public/gesture-hints.js b/public/gesture-hints.js
new file mode 100644
index 00000000..563491f0
--- /dev/null
+++ b/public/gesture-hints.js
@@ -0,0 +1,208 @@
+/* gesture-hints.js — Issue #1065
+ * First-visit gesture discoverability hints.
+ *
+ * - localStorage namespace: meshcore-gesture-hints-
+ * 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; });
+ },
+ };
+})();
diff --git a/public/index.html b/public/index.html
index f9fabc1c..39c6d88a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -109,6 +109,7 @@
+
diff --git a/public/style.css b/public/style.css
index 30a3b891..0a4de2e9 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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 ====================================================== */
diff --git a/test-gesture-hints-1065-e2e.js b/test-gesture-hints-1065-e2e.js
new file mode 100644
index 00000000..ed2abe33
--- /dev/null
+++ b/test-gesture-hints-1065-e2e.js
@@ -0,0 +1,250 @@
+#!/usr/bin/env node
+/* Issue #1065 — Gesture discoverability hints (first-visit).
+ *
+ * Asserts (per parent brief):
+ * (a) on first visit at 360x800 + /#/packets, hint balloon visible after page settle,
+ * with role=status / aria-live=polite region containing swipe-row hint text
+ * (b) tap "Got it" → balloon disappears, localStorage `meshcore-gesture-hints-row-swipe`=`seen`
+ * (c) reload → hint NOT shown (flag persists)
+ * (d) clear flag via Settings UI ("Reset gesture hints") → reload → hint shown again
+ * (e) at 1024x800, edge-swipe hint visible
+ * (f) prefers-reduced-motion: reduce — animation-name 'none' (just opacity fade)
+ * (g) hint does NOT steal focus (document.activeElement === document.body after settle)
+ * (h) singleton: 5 SPA round-trips don't re-show dismissed hints
+ *
+ * Hint timing: brief expects 800ms post-page-settle delay; we wait 1500ms after navigate.
+ */
+'use strict';
+
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+const HINT_SETTLE_MS = 1500;
+
+const KEYS = {
+ rowSwipe: 'meshcore-gesture-hints-row-swipe',
+ tabSwipe: 'meshcore-gesture-hints-tab-swipe',
+ edgeDrawer: 'meshcore-gesture-hints-edge-drawer',
+ pullRefresh: 'meshcore-gesture-hints-pull-refresh',
+};
+
+async function clearAllHintFlags(page) {
+ await page.evaluate((keys) => {
+ Object.values(keys).forEach((k) => localStorage.removeItem(k));
+ }, KEYS);
+}
+
+async function hintVisible(page, hintId) {
+ return page.evaluate((id) => {
+ const el = document.querySelector('[data-gesture-hint="' + id + '"]');
+ if (!el) return { present: false };
+ const cs = getComputedStyle(el);
+ const r = el.getBoundingClientRect();
+ return {
+ present: true,
+ visible: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity || '1') > 0.01 && r.width > 0 && r.height > 0,
+ role: el.getAttribute('role'),
+ ariaLive: el.getAttribute('aria-live'),
+ text: el.textContent || '',
+ animationName: cs.animationName,
+ pointerEvents: cs.pointerEvents,
+ };
+ }, hintId);
+}
+
+async function main() {
+ const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
+ let browser;
+ try {
+ browser = await chromium.launch({
+ headless: true,
+ executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
+ });
+ } catch (err) {
+ if (requireChromium) {
+ console.error(`test-gesture-hints-1065-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
+ process.exit(1);
+ }
+ console.log(`test-gesture-hints-1065-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
+ process.exit(0);
+ }
+
+ let failures = 0, passes = 0;
+ const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
+ const pass = (m) => { passes++; console.log(' PASS: ' + m); };
+
+ // ── (a) first visit on /#/packets at 360x800 → row-swipe hint visible ──
+ const ctx = await browser.newContext({ viewport: { width: 360, height: 800 }, hasTouch: true });
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(15000);
+ page.on('pageerror', (e) => console.error('[pageerror]', e.message));
+
+ // Clear localStorage before first navigate.
+ await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
+ await clearAllHintFlags(page);
+ // Reload to simulate first-visit cleanly.
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(HINT_SETTLE_MS);
+
+ const moduleReady = await page.evaluate(() => typeof window.__gestureHints1065Init === 'number');
+ if (moduleReady) pass('gesture-hints.js loaded (window.__gestureHints1065Init present)');
+ else fail('gesture-hints.js NOT loaded (window.__gestureHints1065Init missing)');
+
+ const rowHint = await hintVisible(page, 'row-swipe');
+ if (rowHint.present && rowHint.visible) {
+ pass('(a) row-swipe hint visible on first visit at /#/packets 360x800');
+ } else {
+ fail(`(a) row-swipe hint NOT visible — state=${JSON.stringify(rowHint)}`);
+ }
+ if (rowHint.role === 'status' && rowHint.ariaLive === 'polite') {
+ pass('(a) hint has role=status and aria-live=polite');
+ } else {
+ fail(`(a) hint missing aria — role=${rowHint.role} aria-live=${rowHint.ariaLive}`);
+ }
+ if (rowHint.pointerEvents === 'none') {
+ pass('(a) hint pointer-events: none — does not capture pointer');
+ } else {
+ fail(`(a) hint pointer-events=${rowHint.pointerEvents}, expected none`);
+ }
+
+ // ── (g) does not steal focus ──
+ const activeTag = await page.evaluate(() => document.activeElement && document.activeElement.tagName);
+ if (activeTag === 'BODY' || activeTag === null || activeTag === 'HTML') {
+ pass(`(g) focus not stolen (activeElement=${activeTag})`);
+ } else {
+ // Allow if active element is not inside the hint.
+ const inHint = await page.evaluate(() => {
+ const a = document.activeElement;
+ if (!a) return false;
+ return !!a.closest('[data-gesture-hint]');
+ });
+ if (!inHint) pass(`(g) focus not in hint (activeElement=${activeTag})`);
+ else fail(`(g) hint stole focus to element inside hint (${activeTag})`);
+ }
+
+ // ── (b) tap "Got it" → balloon gone, localStorage flag set ──
+ const dismissed = await page.evaluate(() => {
+ const el = document.querySelector('[data-gesture-hint="row-swipe"]');
+ if (!el) return { ok: false, reason: 'no hint' };
+ const btn = el.querySelector('[data-gesture-hint-dismiss]');
+ if (!btn) return { ok: false, reason: 'no button' };
+ btn.click();
+ return { ok: true };
+ });
+ if (!dismissed.ok) fail('(b) cannot dismiss: ' + dismissed.reason);
+ await page.waitForTimeout(400);
+ const afterDismiss = await page.evaluate((k) => ({
+ stillThere: !!document.querySelector('[data-gesture-hint="row-swipe"]'),
+ flag: localStorage.getItem(k),
+ }), KEYS.rowSwipe);
+ if (!afterDismiss.stillThere && afterDismiss.flag === 'seen') {
+ pass('(b) "Got it" removed hint and set localStorage flag = "seen"');
+ } else {
+ fail(`(b) dismiss failed — stillThere=${afterDismiss.stillThere} flag=${afterDismiss.flag}`);
+ }
+
+ // ── (c) reload → hint NOT shown ──
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(HINT_SETTLE_MS);
+ const afterReload = await hintVisible(page, 'row-swipe');
+ if (!afterReload.present || !afterReload.visible) {
+ pass('(c) hint NOT shown after reload (flag persisted)');
+ } else {
+ fail('(c) hint reappeared after reload — flag did not persist');
+ }
+
+ // ── (d) clear flag via Settings UI → reload → hint visible again ──
+ // Brief asks for a "Reset gesture hints" button. Click it programmatically
+ // via the UI element if present; otherwise fall back to direct localStorage clear
+ // and FAIL the assertion (the brief requires a UI surface).
+ const resetWorked = await page.evaluate(() => {
+ // Open customize panel.
+ var btn = document.getElementById('customizeToggle');
+ if (btn) btn.click();
+ // The reset button may live anywhere in the panel; look for it by data-attr.
+ var resetBtn = document.querySelector('[data-cv2-reset-hints], [data-reset-gesture-hints]');
+ if (!resetBtn) return { ok: false, reason: 'reset button not found' };
+ resetBtn.click();
+ return { ok: true };
+ });
+ if (!resetWorked.ok) {
+ fail('(d) Settings UI "Reset gesture hints" button not found — ' + resetWorked.reason);
+ // Force-clear so subsequent assertions can run.
+ await clearAllHintFlags(page);
+ } else {
+ pass('(d.1) "Reset gesture hints" button clicked');
+ }
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(HINT_SETTLE_MS);
+ const afterReset = await hintVisible(page, 'row-swipe');
+ if (afterReset.present && afterReset.visible) {
+ pass('(d.2) hint shown again after settings reset');
+ } else {
+ fail(`(d.2) hint NOT shown after reset — state=${JSON.stringify(afterReset)}`);
+ }
+
+ // ── (h) singleton: 5 SPA round-trips don't re-show dismissed hints ──
+ // Dismiss again first.
+ await page.evaluate(() => {
+ const el = document.querySelector('[data-gesture-hint="row-swipe"]');
+ if (el) {
+ const b = el.querySelector('[data-gesture-hint-dismiss]');
+ if (b) b.click();
+ }
+ });
+ await page.waitForTimeout(300);
+ let reShowCount = 0;
+ for (let i = 0; i < 5; i++) {
+ await page.evaluate(() => { location.hash = '#/nodes'; });
+ await page.waitForTimeout(300);
+ await page.evaluate(() => { location.hash = '#/packets'; });
+ await page.waitForTimeout(800);
+ const v = await hintVisible(page, 'row-swipe');
+ if (v.present && v.visible) reShowCount++;
+ }
+ if (reShowCount === 0) pass('(h) 5 SPA round-trips: hint did NOT re-show after dismiss');
+ else fail(`(h) hint re-showed ${reShowCount}/5 SPA round-trips after dismiss`);
+
+ await ctx.close();
+
+ // ── (e) at 1024x800, edge-swipe hint visible on first visit ──
+ const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } });
+ const page2 = await ctx2.newPage();
+ await page2.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
+ await page2.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
+ await page2.reload({ waitUntil: 'domcontentloaded' });
+ await page2.waitForTimeout(HINT_SETTLE_MS);
+ const edgeHint = await hintVisible(page2, 'edge-drawer');
+ if (edgeHint.present && edgeHint.visible) {
+ pass('(e) edge-drawer hint visible at 1024x800');
+ } else {
+ fail(`(e) edge-drawer hint NOT visible at 1024x800 — state=${JSON.stringify(edgeHint)}`);
+ }
+ await ctx2.close();
+
+ // ── (f) prefers-reduced-motion: animation-name = 'none' ──
+ const ctx3 = await browser.newContext({ viewport: { width: 360, height: 800 }, hasTouch: true, reducedMotion: 'reduce' });
+ const page3 = await ctx3.newPage();
+ await page3.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
+ await page3.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
+ await page3.reload({ waitUntil: 'domcontentloaded' });
+ await page3.waitForTimeout(HINT_SETTLE_MS);
+ const reducedHint = await hintVisible(page3, 'row-swipe');
+ if (reducedHint.present && reducedHint.visible) {
+ if (reducedHint.animationName === 'none' || reducedHint.animationName === '' || /none/i.test(String(reducedHint.animationName))) {
+ pass(`(f) prefers-reduced-motion: animation-name=${reducedHint.animationName} (no slide animation)`);
+ } else {
+ fail(`(f) reduced-motion: animation-name=${reducedHint.animationName}, expected 'none'`);
+ }
+ } else {
+ fail(`(f) hint not visible under reduced-motion — state=${JSON.stringify(reducedHint)}`);
+ }
+ await ctx3.close();
+
+ await browser.close();
+ console.log(`\ntest-gesture-hints-1065-e2e.js: ${passes} passed, ${failures} failed`);
+ process.exit(failures > 0 ? 1 : 0);
+}
+
+main().catch((err) => { console.error('test-gesture-hints-1065-e2e.js: FAIL —', err); process.exit(1); });