From 58282c91d8173fbead352102dfdc2fb0d649cab5 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Wed, 27 May 2026 23:21:18 -0700 Subject: [PATCH] fix(#1065): gesture hints touch-gate + width:fit-content + CSS-parse safety (#1452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Three follow-up fixes for #1065 gesture-hint discoverability: 1. **Touch-capability gate.** New `hasTouchCapability()` helper probes `'ontouchstart' in window`, `navigator.maxTouchPoints`, and `(pointer: coarse)`. Every `HINTS[*].relevant()` predicate now returns `false` immediately on mouse-only viewports, so desktop browsers no longer get "swipe a row left" tips. 2. **`width: fit-content` on the pill wrap.** The `.gesture-hint` block previously had no explicit width and defaulted to block-level full-width. Combined with `translateX(-50%)` on `.gesture-hint-bottom` this rendered as a 100vw-wide bar centered with a negative-X transform, i.e. pushed off-screen-left on narrow viewports (384px wrap on 390px viewport). 3. **CSS-parse safety.** Moved the in-body comment (which contained an em-dash) outside the rule block. An earlier attempt to add `width: fit-content` together with an in-body em-dash comment caused the parent `.gesture-hint` rule to vanish from the CSSOM in Chrome (children `.gesture-hint-*` remained). Putting the comment above the block sidesteps the parser bug. ## Test `test-issue-1065-gesture-hints-gates.js` — pure source-file assertions, no browser required. Red commit first (7 fails), green commit second (10/10 pass). Wired into `test-all.sh`. ## Verification After hot-deploy on staging: - Desktop (no touch): `document.querySelectorAll('.gesture-hint').length` === 0 - Mobile emulated (touch): hint rendered, `getBoundingClientRect().x >= 0`, `width <= 360`, `width < viewport_width` - CSSOM: parent `.gesture-hint` rule present with `width: fit-content` + `max-width: 360px` --------- Co-authored-by: openclaw-bot --- public/gesture-hints.js | 18 ++++++ public/style.css | 12 +++- test-all.sh | 1 + test-gesture-hints-1065-e2e.js | 7 ++- test-issue-1065-gesture-hints-gates.js | 82 ++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 test-issue-1065-gesture-hints-gates.js diff --git a/public/gesture-hints.js b/public/gesture-hints.js index 1aee40ed..4566f5d1 100644 --- a/public/gesture-hints.js +++ b/public/gesture-hints.js @@ -31,11 +31,24 @@ 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)/.test(h); @@ -46,6 +59,7 @@ 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]'); }, @@ -55,7 +69,10 @@ 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; edge-swipe drawer is the WIDE + // (>768) layout's nav UI. Below 768, the bottom-nav owns navigation. return window.innerWidth > 768 && !!document.querySelector('.nav-drawer, [data-nav-drawer]'); }, position: 'top-left', @@ -64,6 +81,7 @@ 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'); }, diff --git a/public/style.css b/public/style.css index ea3a7651..ef1a01fd 100644 --- a/public/style.css +++ b/public/style.css @@ -4024,13 +4024,19 @@ body { touch-action: pan-y; } /* === end #1062 ====================================================== */ (feat(#1062): green — implement gesture system) /* === Issue #1065 — Gesture discoverability hints =================== */ +/* width:fit-content makes the pill shrink to its content; without it + * the wrap defaults to block (100% of body width). Combined with + * .gesture-hint-bottom { left:50%; transform:translateX(-50%) } that + * pushed the wrap off-screen-left on narrow viewports (#1065 follow-up). + * Comments live ABOVE the rule body, not interleaved, because a stray + * em-dash or sequence inside the body has historically caused some CSS + * parsers to drop the entire rule from the CSSOM. */ .gesture-hint { position: fixed; z-index: 9999; + width: -moz-fit-content; + width: fit-content; 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; diff --git a/test-all.sh b/test-all.sh index b8dca9a1..1a37c8c9 100755 --- a/test-all.sh +++ b/test-all.sh @@ -12,6 +12,7 @@ echo "── Unit Tests ──" node test-packet-filter.js node test-packet-filter-ux.js node test-aging.js +node test-issue-1065-gesture-hints-gates.js node test-frontend-helpers.js node test-url-state.js node test-perf-go-runtime.js diff --git a/test-gesture-hints-1065-e2e.js b/test-gesture-hints-1065-e2e.js index ed2abe33..b0eb1d42 100644 --- a/test-gesture-hints-1065-e2e.js +++ b/test-gesture-hints-1065-e2e.js @@ -208,8 +208,11 @@ async function main() { await ctx.close(); - // ── (e) at 1024x800, edge-swipe hint visible on first visit ── - const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } }); + // ── (e) at 1024x800 with touch, edge-swipe hint visible on first visit ── + // #1065 follow-up: edge-swipe is a touch gesture; the hint must only + // appear when the viewport reports touch capability. Test context must + // pass hasTouch:true (real edge-swipe-on-tablet/touch-laptop scenario). + const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 }, hasTouch: true }); 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); diff --git a/test-issue-1065-gesture-hints-gates.js b/test-issue-1065-gesture-hints-gates.js new file mode 100644 index 00000000..f18976eb --- /dev/null +++ b/test-issue-1065-gesture-hints-gates.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/* Issue #1065 follow-up — gesture hints must: + * 1. Define a hasTouchCapability() helper that probes ontouchstart, + * maxTouchPoints, and (pointer: coarse). + * 2. Gate every HINTS[*].relevant() body on hasTouchCapability() at the + * very top (no hint should fire on mouse-only viewports). + * 3. Ship a .gesture-hint parent CSS rule that includes + * `width: fit-content` AND `max-width: 360px` so the pill shrinks to + * its content instead of stretching full-bleed and being pushed + * off-screen by translateX(-50%) on narrow viewports. + * + * Pure source-file assertions — no browser required. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const JS_PATH = path.join(__dirname, 'public', 'gesture-hints.js'); +const CSS_PATH = path.join(__dirname, 'public', 'style.css'); + +let failures = 0, passes = 0; +const fail = (m) => { failures++; console.error(' FAIL: ' + m); }; +const pass = (m) => { passes++; console.log(' PASS: ' + m); }; + +const js = fs.readFileSync(JS_PATH, 'utf8'); +const css = fs.readFileSync(CSS_PATH, 'utf8'); + +// (1) helper exists and probes the three signals +if (/function\s+hasTouchCapability\s*\(/.test(js)) pass('hasTouchCapability() defined'); +else fail('hasTouchCapability() not defined in gesture-hints.js'); + +if (/ontouchstart/.test(js)) pass('hasTouchCapability probes ontouchstart'); +else fail('hasTouchCapability missing ontouchstart probe'); + +if (/maxTouchPoints/.test(js)) pass('hasTouchCapability probes maxTouchPoints'); +else fail('hasTouchCapability missing maxTouchPoints probe'); + +if (/pointer:\s*coarse/.test(js)) pass('hasTouchCapability probes (pointer: coarse)'); +else fail('hasTouchCapability missing (pointer: coarse) probe'); + +// (2) every relevant() body must start with the touch gate +// Find each `relevant: function () { ... }` block and check. +const relevantRe = /relevant:\s*function\s*\(\s*\)\s*\{([\s\S]*?)\n\s{6}\}/g; +let m, count = 0, gated = 0; +while ((m = relevantRe.exec(js)) !== null) { + count++; + const body = m[1]; + // First non-comment statement must be hasTouchCapability gate + if (/^\s*if\s*\(\s*!\s*hasTouchCapability\s*\(\s*\)\s*\)\s*return\s+false\s*;/m.test(body)) { + gated++; + } +} +if (count >= 4) pass(`found ${count} relevant() predicates`); +else fail(`expected ≥4 relevant() predicates, found ${count}`); +if (gated === count && count > 0) pass(`all ${gated}/${count} relevant() bodies start with !hasTouchCapability() return false`); +else fail(`only ${gated}/${count} relevant() bodies gate on hasTouchCapability()`); + +// (3) .gesture-hint parent rule has width: fit-content + max-width: 360px +// Locate the rule block starting `.gesture-hint {` (NOT .gesture-hint-...). +const ruleRe = /\n\.gesture-hint\s*\{([\s\S]*?)\}/; +const ruleMatch = ruleRe.exec(css); +if (!ruleMatch) { + fail('.gesture-hint parent CSS rule not found in style.css'); +} else { + pass('.gesture-hint parent CSS rule present'); + const body = ruleMatch[1]; + if (/\bwidth:\s*fit-content\b/.test(body)) pass('.gesture-hint declares width: fit-content'); + else fail('.gesture-hint missing width: fit-content (pill must shrink to content)'); + if (/\bmax-width:\s*360px\b/.test(body)) pass('.gesture-hint declares max-width: 360px'); + else fail('.gesture-hint missing max-width: 360px'); +} + +// (4) defensive: no em-dash or stray "*/" inside .gesture-hint rule body +if (ruleMatch) { + const body = ruleMatch[1]; + if (/[\u2014\u2013]/.test(body)) fail('em-dash / en-dash inside .gesture-hint rule body (CSS-parse-fragile)'); + else pass('no em-dash inside .gesture-hint rule body'); +} + +console.log(`\ntest-issue-1065-gesture-hints-gates.js: ${passes} passed, ${failures} failed`); +process.exit(failures > 0 ? 1 : 0);