qa-detect-ux-feedback
Detects UX feedback gaps: empty states without illustration/CTA, tables with no rows + no empty message, modals without visible close button, modals not dismissible via ESC/backdrop, search bars witho
Files1
View on GitHubSKILL.mdskills/qa-detect-ux-feedback/SKILL.md
GitHubWhat it catches — 8 issue types
| issueType | severity | What |
|---|---|---|
tableNoEmptyState |
medium | Table has thead but tbody is empty AND no nearby empty-state element ("No results", "No data", etc.). Users see blank table and assume broken. |
listNoEmptyState |
medium | List/grid (ul, [role="list"], .list-grid) is empty AND no empty-state element nearby. |
modalNoCloseButton |
high | Open dialog/modal has no visible close button (X icon, "Close", "Cancel"). User has no clear exit. |
modalNoEscDismiss |
medium | Open modal has no data-dismiss, no aria-modal, and no detected ESC key handler — keyboard users can't close it. |
modalNoBackdropDismiss |
low | Open modal has backdrop element but no detected click handler to dismiss on backdrop click. |
searchPlaceholderTooGeneric |
low | Search input placeholder is a generic 1-3 word phrase ("Search budgets...") while the adjacent table has 3+ searchable text columns — users don't know which fields will be searched |
loadingNoIndicator |
medium | Element matched aria-busy="true" or [class*="loading"] is present but has no visible spinner/progress text inside. |
staleSyncTimestamp |
high | Page shows "Last synced: Xh ago" (> 1 hour old) while a sync/refresh button exists — clicking sync didn't update the timestamp, data is stale. |
Moved out:
searchNoClearButtonis now produced by the interactive skill qa-test-data-controls (it must type real keystrokes before it can judge whether a clear button conditionally appears).
Probe (browser_evaluate)
() => {
const sel = el => {
const id = el.id ? '#' + el.id : '';
const cls = (el.className && typeof el.className === 'string')
? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '';
return (el.tagName.toLowerCase() + id + cls).slice(0, 120);
};
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) };
};
const visible = el => {
if (!el || el.nodeType !== 1) return false;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
const out = [];
// ── 1. Table with no rows + no empty state ────────────────────────────
let tableEmptyFlagged = 0;
for (const tbl of document.querySelectorAll('table')) {
if (tableEmptyFlagged >= 3) break;
if (!visible(tbl)) continue;
const hasHead = !!tbl.querySelector('thead, tr:first-child th');
if (!hasHead) continue;
const bodyRows = tbl.querySelectorAll('tbody tr');
if (bodyRows.length > 0) continue;
// Look for an empty-state nearby (within parent or next sibling)
const container = tbl.closest('.table-wrapper, .table-container, .data-table-wrapper') || tbl.parentElement;
const emptyText = /no\s+(?:data|records|results|items|rows|entries)|empty/i;
const hasEmptyMsg = container && [...container.querySelectorAll('div, p, span, h1, h2, h3, h4, h5, h6')]
.some(el => visible(el) && emptyText.test(el.innerText || ''));
if (!hasEmptyMsg) {
tableEmptyFlagged++;
out.push({
issueType: 'tableNoEmptyState', severity: 'medium',
selector: sel(tbl), bbox: bb(tbl),
description: 'Table has header columns but zero rows AND no "No data" / empty-state message nearby. Users see blank area and assume the page is broken.'
});
}
}
// ── 2. List/grid empty state ──────────────────────────────────────────
let listEmptyFlagged = 0;
for (const list of document.querySelectorAll('ul, ol, [role="list"], .list, .grid-list, .card-list')) {
if (listEmptyFlagged >= 2) break;
if (!visible(list)) continue;
if (list.closest('nav, footer, header, aside')) continue;
const items = [...list.children].filter(c => c.tagName === 'LI' || c.getAttribute('role') === 'listitem' || c.classList.contains('card') || c.classList.contains('item'));
if (items.length > 0) continue;
const rect = list.getBoundingClientRect();
if (rect.height < 24) continue; // collapsed
const container = list.parentElement;
const emptyText = /no\s+(?:items|results|records|matches|entries|data)|empty/i;
const hasEmptyMsg = container && [...container.querySelectorAll('div, p, span')]
.some(el => visible(el) && emptyText.test(el.innerText || ''));
if (!hasEmptyMsg) {
listEmptyFlagged++;
out.push({
issueType: 'listNoEmptyState', severity: 'medium',
selector: sel(list), bbox: bb(list),
description: 'List/grid is empty with no "No items" / empty-state message. Users may think the page failed to load.'
});
}
}
// ── 3-5. Modal dismissibility (only open modals) ──────────────────────
const openModals = [...document.querySelectorAll('dialog[open], [role="dialog"]:not([hidden]), [role="alertdialog"]:not([hidden]), .modal.show, .modal.is-open, [class*="modal"]:not([hidden])')]
.filter(visible);
for (const m of openModals.slice(0, 2)) {
// 3. Close button visible?
const closeBtn = m.querySelector('[aria-label*="close" i], [data-dismiss="modal"], .close, .modal-close, [class*="close-button"], button[title*="close" i]');
if (!closeBtn || !visible(closeBtn)) {
out.push({
issueType: 'modalNoCloseButton', severity: 'high',
selector: sel(m), bbox: bb(m),
description: 'Open modal has no visible close button (X icon, "Close", aria-label="close"). User has no clear exit — only browser back-button works.'
});
}
// 4. ESC dismiss handler
const hasEscHook = m.hasAttribute('data-dismiss') ||
m.getAttribute('aria-modal') === 'true' ||
m.hasAttribute('data-keyboard') ||
m.querySelector('[data-keyboard], [data-dismiss="modal"]');
if (!hasEscHook) {
out.push({
issueType: 'modalNoEscDismiss', severity: 'medium',
selector: sel(m), bbox: bb(m),
description: 'Modal has no aria-modal, no data-dismiss, no detected ESC handler. Keyboard users may be trapped.'
});
}
// 5. Backdrop dismiss
const backdrop = document.querySelector('.modal-backdrop, .modal-overlay, [class*="backdrop"]');
if (backdrop && visible(backdrop)) {
const onclick = (backdrop.getAttribute('onclick') || '');
const hasBackdropHandler = onclick.length > 0 ||
backdrop.hasAttribute('data-dismiss') ||
backdrop.classList.contains('cdk-overlay-backdrop');
if (!hasBackdropHandler) {
out.push({
issueType: 'modalNoBackdropDismiss', severity: 'low',
selector: sel(backdrop), bbox: bb(backdrop),
description: 'Modal backdrop exists but no detected click handler to dismiss. Users expect backdrop click to close.'
});
}
}
}
// ── Loading element with no indicator inside (REAL: blank, no feedback) ─
let loadingFlagged = 0;
const loadingEls = document.querySelectorAll('[aria-busy="true"], .loading, .is-loading, [class*="spinner"], [class*="skeleton"]');
for (const el of loadingEls) {
if (loadingFlagged >= 2) break;
if (!visible(el)) continue;
const r = el.getBoundingClientRect();
if (r.width < 40 || r.height < 20) continue; // probably IS the spinner itself
const hasSpinner = !!el.querySelector('svg[class*="spin"], .spinner, [class*="rotate"], [class*="animate"], progress, [role="progressbar"]');
const txt = (el.innerText || '').trim().toLowerCase();
const hasText = /loading|please wait|fetching|saving|sending/.test(txt);
if (!hasSpinner && !hasText) {
loadingFlagged++;
out.push({
issueType: 'loadingNoIndicator', severity: 'medium',
selector: sel(el), bbox: bb(el),
description: 'Element marked as loading (aria-busy or .loading) but has no visible spinner / progress / "Loading..." text. Users see a blank area with no feedback.'
});
}
}
// ── Stale sync timestamp ─────────────────────────────────────────────────
// Detects "Last synced: 5h ago" / "Last updated: 2 hours ago" indicators where
// the data is > 1 hour old AND a sync/refresh button is present on the page.
// Scenario: user clicks "Refresh Sync" but the timestamp never updates — sync pipeline failure.
const SYNC_LABEL = /last\s+sync(?:ed)?|last\s+updat(?:ed?)|sync(?:ed)?\s+\d|updated\s+\d|synced\s+\w+\s+ago|refreshed\s+\w+\s+ago/i;
// Stale = ≥ 2 hours ago, or any number of days/weeks/months, or "yesterday"
const STALE_TIME = /\b([2-9]\d*|\d{2,})\s*h(?:ours?)?\s*ago\b|\b\d+\s*d(?:ays?)?\s*ago\b|\byesterday\b|\b\d+\s+(?:hours?|days?|weeks?|months?)\s+ago\b/i;
const syncEls = [...document.querySelectorAll(
'span, p, div, small, time, label, [class*="sync"], [class*="timestamp"], [class*="last-sync"], [class*="last-update"], [class*="refreshed"]'
)].filter(el => {
if (!visible(el)) return false;
const t = (el.innerText || el.textContent || '').trim();
return t.length > 0 && t.length <= 120;
});
for (const el of syncEls) {
const text = (el.innerText || el.textContent || '').trim();
if (!SYNC_LABEL.test(text)) continue;
if (!STALE_TIME.test(text)) continue;
// Look for a sync/refresh button anywhere on the page
const allBtns = [...document.querySelectorAll('button, [role="button"], a.btn')].filter(visible);
const syncBtn = allBtns.find(b => {
const label = ((b.innerText || '') + ' ' + (b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || '')).toLowerCase();
return /sync|refresh|reload|update|re-?sync/.test(label);
});
const staleMatch = text.match(STALE_TIME);
out.push({
issueType: 'staleSyncTimestamp', severity: 'high',
selector: sel(el), bbox: bb(el),
description: `Data is stale: "${text.slice(0, 80)}" — last sync was ${staleMatch ? staleMatch[0] : 'more than 1 hour ago'}. ${syncBtn ? `A "${(syncBtn.innerText || '').trim().slice(0,20)}" button exists but clicking it did not refresh the timestamp — investigate the sync pipeline (background job, API endpoint, or UI state not updating after sync completes).` : 'No sync/refresh button found to trigger a manual refresh.'}`
});
break; // one finding per page
}
// ── searchNoClearButton check moved to qa-test-data-controls ──
// Why this used to be here and now isn't: clear-X buttons in modern apps are
// conditional on the input having a value, and that value-state is owned by
// the framework (React/Angular/Vue), not the DOM. Setting `input.value` via
// the native setter + dispatching synthetic `input` events from a passive
// probe does NOT trigger framework re-renders the way real keystrokes do, so
// the clear button never appeared and EVERY search input got falsely flagged.
// The check now lives in qa-test-data-controls where a real `browser_type`
// tool is available — see "Clear button after typing" in that skill.
return out;
}
Notes
- Self-skips: page with no tables/lists/modals/loading states returns []
- Modal checks only fire when a modal is currently OPEN (so empty result on most cells is correct)
- The
tableNoEmptyStateis high-impact on data-driven apps with frequent empty states searchNoClearButtontypes a test value first — never checks empty-state DOM, avoids false positives on apps with conditional clear buttons