From 4e0a168bc0a50fa648bc810516197cd1048a3d6f Mon Sep 17 00:00:00 2001 From: openclaw-bot Date: Sun, 10 May 2026 02:03:40 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test(#1065):=20red=20=E2=80=94=20gesture=20?= =?UTF-8?q?discoverability=20hints=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Playwright E2E covering: - first-visit row-swipe hint shown at 360x800 /#/packets - aria-live=polite, role=status, pointer-events:none - 'Got it' button dismisses + persists meshcore-gesture-hints-* flag - reload does not re-show - Settings 'Reset gesture hints' button restores - 1024x800 edge-drawer hint visible - prefers-reduced-motion → animation-name: none - focus not stolen - 5 SPA round-trips don't re-show after dismiss Wired into .github/workflows/deploy.yml. Will FAIL on assertion until public/gesture-hints.js + Settings reset button + CSS land in the green commit. --- .github/workflows/deploy.yml | 1 + test-gesture-hints-1065-e2e.js | 250 +++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 test-gesture-hints-1065-e2e.js 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/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); }); From b0c68ac183bcf30bb14a4518f92d9ba81ebce21f Mon Sep 17 00:00:00 2001 From: openclaw-bot Date: Sun, 10 May 2026 02:06:05 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(#1065):=20green=20=E2=80=94=20first-vi?= =?UTF-8?q?sit=20gesture=20discoverability=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements gesture discoverability hints per AC: - Hints: row-swipe, tab-swipe, edge-drawer, pull-refresh - localStorage namespace: meshcore-gesture-hints- = 'seen' - Show 800ms after page settle; auto-fade after 8s; 'Got it' marks seen - aria-live=polite + role=status + aria-atomic=true - pointer-events:none on wrapper (inner balloon clickable, row stays usable) - Singleton guard via window.__gestureHints1065Init - prefers-reduced-motion: animation-name:none, opacity-only fade - Pull-to-refresh hint conditional on .pull-to-reconnect element presence - Edge-drawer hint conditional on viewport > 768px - Row-swipe hint scoped to /#/packets, /#/nodes, /#/live - 'Reset gesture hints' button added to Customize > Display panel - hashchange listener removes irrelevant hints + re-evaluates on route change Files: - NEW public/gesture-hints.js (module-scoped singleton) - NEW CSS section in public/style.css (#1065 fence) - public/index.html: script tag after touch-gestures.js - public/customize-v2.js: 'Reset gesture hints' button + handler --- public/customize-v2.js | 16 ++++ public/gesture-hints.js | 204 ++++++++++++++++++++++++++++++++++++++++ public/index.html | 1 + public/style.css | 77 +++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 public/gesture-hints.js 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..01561ff5 --- /dev/null +++ b/public/gesture-hints.js @@ -0,0 +1,204 @@ +/* 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; + 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..72c811fe 100644 --- a/public/style.css +++ b/public/style.css @@ -3554,3 +3554,80 @@ 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; + pointer-events: none; + 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 ====================================================== */ From 0e11ff0fa20450ec4757ac172e2de22e44f2b51e Mon Sep 17 00:00:00 2001 From: corescope-bot Date: Sun, 10 May 2026 02:42:07 +0000 Subject: [PATCH 3/4] fix(#1065): force pointer-events:none on hint wrapper with !important MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI test (a) reported computed pointer-events='auto' on the wrapper despite .gesture-hint { pointer-events: none } — likely a cascade collision (e.g. .gesture-hint-inner sibling rule, or a future overlay class). Force it with !important. Inner balloon + button retain pointer-events:auto so 'Got it' still works. --- public/style.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/style.css b/public/style.css index 72c811fe..0a4de2e9 100644 --- a/public/style.css +++ b/public/style.css @@ -3560,7 +3560,10 @@ body { touch-action: pan-y; } position: fixed; z-index: 9999; max-width: 360px; - pointer-events: none; + /* !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; From c55fa99d7183fb2858cdf45e8a8482ab2ab9642d Mon Sep 17 00:00:00 2001 From: corescope-bot Date: Sun, 10 May 2026 02:48:21 +0000 Subject: [PATCH 4/4] fix(#1065): inline pointer-events:none on hint wrapper (bypass cascade) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS rule + !important alone didn't satisfy the E2E (Chromium reported computed pointer-events=auto despite '.gesture-hint { pointer-events: none !important }'). Most likely: cached page state during test, or a sibling rule we haven't identified. Inline style.pointerEvents='none' on the wrapper at creation time bypasses the entire CSS cascade — the hint provably cannot capture clicks. Inner balloon + button keep their own pointer-events:auto via separate elements. --- public/gesture-hints.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/gesture-hints.js b/public/gesture-hints.js index 01561ff5..563491f0 100644 --- a/public/gesture-hints.js +++ b/public/gesture-hints.js @@ -81,6 +81,10 @@ 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');