fix(#1065): gesture hints touch-gate + width:fit-content + CSS-parse safety (#1452)

## 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 <bot@openclaw.local>
This commit is contained in:
Kpa-clawbotandopenclaw-bot authored and GitHub committed 2026-05-27 23:21:18 -07:00
1 parent 17d00c8366
commit 58282c91d8
5 files changed
+115 -5

No files matched your search

+18
View File
@@ -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');
},
+9 -3
View File
@@ -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;
+1
View File
@@ -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
+5 -2
View File
@@ -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);
+82
View File
@@ -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);