qa-detect-touch-interactions
Dispatches real TouchEvent (touchstart/touchend) on elements that have hover-only handlers, to detect controls that work with mouse but not with touch. Tests tap, double-tap-to-zoom suppression, swipe
SKILL.mdskills/qa-detect-touch-interactions/SKILL.md
GitHubHow the orchestrator runs this (ONE call — no hand-driving)
🚨 This skill is an EXECUTABLE in-page probe, not a prose playbook. Do NOT drive it with separate find/state/dispatch/recheck MCP calls. Instead make ONE call:
result = browser_evaluate(<the async function in "## Interactive Probe" below>)
The function finds up to 8 interactive targets, captures each element's state, dispatches a genuine TouchEvent (touchstart/touchend) sequence, waits in-page, re-checks state, then checks viewport-meta/touch-action policy and tests horizontal-scroll containers with a synthetic swipe — returning findings[] in one round-trip. It does its own waits via in-page setTimeout promises, so there is no AI reasoning between steps. It self-skips (returns []) when the page has no interactive elements or the browser lacks TouchEvent/Touch constructors. The probe closes any menus it opened before returning, leaving the page clean for the next skill. Transcribe each returned finding verbatim into the cell JSONL; add only the envelope fields (runId, cellId, route, viewport, …).
Run on mobile/tablet only (the orchestrator gates applyOn: [mobile, tablet]). Never dispatches on submit/delete/logout controls — the probe filters them out.
Interactive Probe (browser_evaluate, async)
async () => {
const sleep = ms => new Promise(r => setTimeout(r, ms));
const out = [];
const add = o => out.push(Object.assign({ skill: 'qa-detect-touch-interactions' }, o));
const sel = el => { if (!el) return null; if (el.id) return '#' + el.id; const c = (el.className && typeof el.className === 'string') ? el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''; return el.tagName.toLowerCase() + (c ? '.' + c : ''); };
const bb = el => { const r = el.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }; };
// self-skip if TouchEvent unsupported
if (typeof window.TouchEvent === 'undefined' || typeof window.Touch === 'undefined') return [];
const stateOf = el => {
const adjacent = el.parentElement ? [...el.parentElement.children].filter(c => c !== el) : [];
const expandedSiblings = adjacent.filter(s => { const r = s.getBoundingClientRect(); return r.width > 0 && r.height > 0 && (s.getAttribute('aria-expanded') === 'true' || /open|active|expanded|show/i.test(s.className)); });
return JSON.stringify({ ariaExpanded: el.getAttribute('aria-expanded'), classList: el.className, visibleSiblings: expandedSiblings.length, scrollY: window.scrollY });
};
const tap = el => {
const r = el.getBoundingClientRect();
const x = r.left + r.width / 2, y = r.top + r.height / 2;
const t = new Touch({ identifier: 1, target: el, clientX: x, clientY: y, pageX: x, pageY: y, screenX: x, screenY: y, radiusX: 10, radiusY: 10, force: 1 });
el.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t] }));
el.dispatchEvent(new TouchEvent('touchend', { bubbles: true, cancelable: true, touches: [], targetTouches: [], changedTouches: [t] }));
};
// ── find targets (skip dangerous controls) ──
const targets = [];
for (const el of document.querySelectorAll('button, a, [role="button"], [onclick], [onmouseover], [data-toggle], summary, label[for]')) {
if (targets.length >= 8) break;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
const txt = (el.innerText || '').toLowerCase();
if (el.type === 'submit' || /delete|remove|destroy|logout|sign\s*out|submit|save/i.test(txt)) continue;
const className = (el.className && typeof el.className === 'string') ? el.className : '';
const tag = el.tagName;
// A genuine HOVER-REVEAL behavior means JS hover listeners (onmouseover/onmouseenter) OR a
// CSS class that names hover. A class merely containing "hover" is weak — require an actual
// JS hover handler OR a clearly hover-driven menu/dropdown class for it to count as hover-only-suspect.
const jsHover = !!el.onmouseover || !!el.onmouseenter;
const hoverClass = /(hover[-_]?(menu|dropdown|reveal|trigger)|dropdown[-_]?hover|menu[-_]?hover)/i.test(className);
// A CLICK / TOUCH / KEYBOARD equivalent means the control ALSO works without hover:
// - real semantic link/button (native activation on tap)
// - explicit onclick / [onclick]
// - keyboard-activatable (tabindex + role=button/link/menuitem, native interactive el)
const isRealLinkOrButton = (tag === 'A' && el.hasAttribute('href')) || tag === 'BUTTON' || tag === 'SUMMARY';
const role = el.getAttribute('role');
const keyboardActivatable = el.hasAttribute('tabindex') && ['button','link','menuitem','tab'].includes(role || '');
const hasClickEquivalent = !!el.onclick || el.hasAttribute('onclick') || isRealLinkOrButton || keyboardActivatable;
targets.push({
el,
hasHoverHandler: jsHover || hoverClass,
hasTouchHandler: !!el.ontouchstart || !!el.ontouchend,
hasClickEquivalent,
hasClickHandler: !!el.onclick || el.hasAttribute('onclick') || tag === 'BUTTON' || tag === 'A'
});
}
if (targets.length === 0) return [];
// ── test each target with a real tap ──
for (const t of targets) {
const before = stateOf(t.el);
tap(t.el);
await sleep(300);
const after = stateOf(t.el);
// hoverOnlyMenu — ONLY a defect when the control reveals on hover AND has NO click/touch/keyboard
// equivalent. A nav item that opens its submenu on BOTH hover and click is NOT a defect; a real
// <a href>/<button> activates on tap natively, so "no observed state change" from our heuristic
// stateOf() is not proof it's dead — require a genuine absence of any touch-reachable path.
if (t.hasHoverHandler && !t.hasTouchHandler && !t.hasClickEquivalent && after === before)
add({ issueType: 'hoverOnlyMenu', severity: 'high', selector: sel(t.el), bbox: bb(t.el), description: 'Element reveals on hover but has no click/touch/keyboard equivalent and did not respond to a real touchstart/touchend tap — hover-only, invisible to mobile/touch users.', evidence: { hasHoverHandler: true, hasTouchHandler: false, hasClickEquivalent: false } });
else if (t.hasClickHandler && !t.hasTouchHandler && after === before)
add({ issueType: 'touchEventIgnored', severity: 'high', selector: sel(t.el), bbox: bb(t.el), description: 'Element has a click handler but nothing changed on a real touch tap, and touchend did not bubble to click on this app.', evidence: { hasClickHandler: true } });
}
// ── viewport meta + touch-action policy ──
const meta = document.querySelector('meta[name="viewport"]');
const metaContent = meta ? (meta.getAttribute('content') || '') : '';
const hasWidthDevice = /width\s*=\s*device-width/.test(metaContent);
if (!hasWidthDevice)
add({ issueType: 'tapDelayDetected', severity: 'low', selector: 'meta[name="viewport"]', description: 'No viewport meta with width=device-width — legacy 300ms tap delay likely affects every tap.', evidence: { metaContent } });
let blockedCount = 0, interactiveSeen = 0;
for (const el of document.querySelectorAll('button, a, [role="button"]')) {
interactiveSeen++;
const s = getComputedStyle(el);
if (s.touchAction === 'manipulation' || s.touchAction === 'none') blockedCount++;
if (interactiveSeen > 20) break;
}
if (interactiveSeen > 0 && blockedCount === interactiveSeen)
add({ issueType: 'doubleTapZoomBlocked', severity: 'medium', selector: 'body', description: 'All sampled interactive elements set touch-action: manipulation/none — legitimate double-tap-to-zoom is blocked everywhere.', evidence: { blockedCount, interactiveSeen } });
// ── horizontal-scroll containers: synthetic swipe ──
const carousels = [];
for (const el of document.querySelectorAll('*')) {
if (carousels.length >= 5) break;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.width > innerWidth * 0.5) continue;
const s = getComputedStyle(el);
if (s.overflowX !== 'scroll' && s.overflowX !== 'auto') continue;
if (el.scrollWidth <= el.clientWidth + 5) continue;
carousels.push(el);
}
for (const el of carousels) {
const r = el.getBoundingClientRect();
const startX = r.left + r.width * 0.8, endX = r.left + r.width * 0.2, y = r.top + r.height / 2;
const mk = x => new Touch({ identifier: 1, target: el, clientX: x, clientY: y, pageX: x, pageY: y, force: 1 });
const before = el.scrollLeft;
el.dispatchEvent(new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [mk(startX)], targetTouches: [mk(startX)], changedTouches: [mk(startX)] }));
el.dispatchEvent(new TouchEvent('touchmove', { bubbles: true, cancelable: true, touches: [mk((startX + endX) / 2)], targetTouches: [mk((startX + endX) / 2)], changedTouches: [mk((startX + endX) / 2)] }));
el.dispatchEvent(new TouchEvent('touchend', { bubbles: true, cancelable: true, touches: [], targetTouches: [], changedTouches: [mk(endX)] }));
await sleep(200);
if (el.scrollLeft === before)
add({ issueType: 'swipeNotHandled', severity: 'medium', selector: sel(el), bbox: bb(el), description: 'Horizontally-overflowing container did not scroll after a synthetic swipe — touch users cannot navigate this carousel/strip.', evidence: { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth } });
}
// ── cleanup: close any menus opened during tapping ──
for (const el of document.querySelectorAll('[aria-expanded="true"]')) { try { el.setAttribute('aria-expanded', 'false'); } catch (_) {} }
return out;
}
Issues
| issueType | severity | description |
|---|---|---|
| hoverOnlyMenu | high | Menu opens on mouseenter but does NOT open on touchstart — invisible to mobile users |
| touchEventIgnored | high | Button has click handler but no touchend handler, and touchend doesn't bubble to click on this app |
| doubleTapZoomBlocked | medium | Page sets touch-action: manipulation everywhere, blocking legitimate double-tap-to-zoom |
| tapDelayDetected | low | App has 300ms tap delay because no viewport meta tag with width=device-width |
| swipeNotHandled | medium | Element has overflow-x: scroll content but no swipe handler — touch users can't navigate carousel |
Hard rules
- Never dispatch on form submit / delete / logout buttons — the probe filters targets by type/text before tapping.
- Mandatory cleanup — opened menus are closed (
aria-expanded="false") before the probe returns. - Mobile/tablet only — desktop touch testing is theoretical.
- 300 ms wait between dispatch and recheck — allows JS handlers to update the DOM.
Notes on this conversion
- This replaces the old ~30-call flow (find → per-target state/dispatch/wait/recheck → policy → swipe → cleanup) with ONE async
browser_evaluate. All inter-step waits are now in-pagesetTimeoutpromises, so there is no AI reasoning between dispatches. - All 5 issueTypes preserved.
- Detection of hover handlers remains HEURISTIC (no way to inspect attached listeners) — false negatives possible, same as before.
TouchEvent/Touchconstructors are supported in Playwright Chromium/Firefox/WebKit; the probe self-skips if unavailable.