qa-detect-ux-empty-state-conflict
Detects the 'system says no results but shows old results anyway' bug class — empty-state toast/banner saying 'Record Not Found' / 'No data' visible at the same time as result cards, rows, or list ite
SKILL.mdskills/qa-detect-ux-empty-state-conflict/SKILL.md
GitHubWhat it catches — 4 issue types
| issueType | severity | What |
|---|---|---|
emptyStateMessageWithVisibleResults |
high | An empty-state message ("No record found", "No data", "0 results", "Nothing here") is visible while result items (cards, table rows, list items) are ALSO visible on the same page. After a filter returned 0 records the previous results should clear. (Your Students screenshot: "Record Not Found" toast with RIDA NASIR card still showing.) |
paginationVisibleWithEmptyState |
medium | Pagination controls (page numbers, Next/Prev) are visible while an empty-state message is also visible — pagination is meaningless without data. (Your screenshot: "1" page indicator shown even with "Record Not Found".) |
duplicateToastSimultaneous |
medium | Two or more visible toasts share identical text — the system fired the same notification multiple times instead of de-duplicating. (Your screenshot: two stacked "Record Not Found" toasts.) |
emptyStateInWrongLocation |
low | Empty-state message appears as a top-right toast/alert when it should be inline in the result area, OR appears inline when results actually exist (stale empty-state) |
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' || parseFloat(cs.opacity) < 0.1) return false;
const r = el.getBoundingClientRect();
return r.width > 4 && r.height > 4;
};
const out = [];
// Empty-state text patterns (case-insensitive, allow trailing punctuation)
const emptyRe = /^(record\s*not\s*found|no\s*record(s)?\s*(found|available)?|no\s*data(\s*found)?|no\s*results?(\s*found)?|nothing\s*(found|here)|0\s*results?|empty\s*list|not\s*found)\.?!?$/i;
const isEmptyText = t => emptyRe.test((t || '').trim());
// 1) Collect all visible toasts/alerts/snackbars on the page
const toastSel = '[role="alert"], [role="status"], [class*="toast"], [class*="Toast"], [class*="snackbar"], [class*="Snackbar"], [class*="notification"], [class*="Notification"], .alert, .Alert';
const toasts = [...document.querySelectorAll(toastSel)]
.filter(visible)
.filter(el => {
// Toasts must be reasonably small (banners or pop-ups), not whole page sections
const r = el.getBoundingClientRect();
return r.width < window.innerWidth * 0.7 && r.height < 200;
});
// 2) Find empty-state messages — could be in a toast, a banner, or inline text
const emptyStateEls = [];
// (a) Toasts whose text matches empty-state pattern
for (const t of toasts) {
const txt = (t.innerText || '').trim();
// Toast often includes close button "×" — scan EACH line plus the whole text
const lines = txt.split(/\n/).map(s => s.trim()).filter(Boolean);
// Match line whose substantive part (after stripping × and close labels) is an empty-state phrase
const matchedLine = lines.find(line => {
const cleaned = line.replace(/^\s*[×x✕✖✗]\s*/i, '').replace(/\s*[×x✕✖✗]\s*$/i, '').replace(/\s*close\s*$/i, '').trim();
return isEmptyText(cleaned) || /not\s*found|no\s*record|no\s*data|no\s*results?|nothing\s*found/i.test(cleaned.slice(0, 60));
});
// Also try the whole text as a fallback (handles single-line toasts)
const wholeMatches = /not\s*found|no\s*record|no\s*data|no\s*results?|nothing\s*found/i.test(txt.slice(0, 100));
if (matchedLine || wholeMatches) {
const display = matchedLine || txt.replace(/[×x✕✖✗\s]+/g, ' ').trim();
emptyStateEls.push({ el: t, text: display, kind: 'toast' });
}
}
// (b) Inline empty-state messages (h2/h3/p inside a result region with empty-state phrase)
const inlineCandidates = document.querySelectorAll(
'[class*="empty"], [class*="Empty"], [class*="no-data"], [class*="noData"], [class*="no-results"], [class*="noResults"], [class*="not-found"], [class*="notFound"], ' +
'.result-area p, .results p, [class*="result"] > p, [class*="list"] > p'
);
for (const el of inlineCandidates) {
if (!visible(el)) continue;
const txt = (el.innerText || '').trim();
if (txt.length > 0 && txt.length < 80 && (isEmptyText(txt) || /no\s*record|no\s*data|no\s*results?|not\s*found/i.test(txt))) {
emptyStateEls.push({ el, text: txt, kind: 'inline' });
}
}
// 3) Look at result-area candidates (cards, table rows, list items in the MAIN area)
// Identify the main content area (skip nav/sidebar/header)
const main = document.querySelector('main, [role="main"], .main-content, .content-area, [class*="main-content"]') || document.body;
const resultSel = [
'.card', '[class*="card"]:not([class*="card-header"]):not([class*="card-title"])',
'tbody tr',
'[role="row"]:not([role="rowheader"])',
'li[class*="item"]', 'li[class*="row"]',
'[class*="list-item"]', '[class*="ListItem"]',
'[class*="result-item"]', '[class*="resultItem"]',
'[class*="record"]', '[class*="Record"]'
].join(', ');
const resultItems = [...main.querySelectorAll(resultSel)]
.filter(visible)
.filter(el => {
// Filter out tiny items, headers, footers, sidebar elements
const r = el.getBoundingClientRect();
if (r.width < 80 || r.height < 30) return false;
// Skip items inside a known sidebar/nav
if (el.closest('nav, aside, [class*="sidebar"], [class*="Sidebar"], header, [class*="navbar"], [role="navigation"]')) return false;
// Skip items inside the empty-state toast itself
if (emptyStateEls.some(e => e.el === el || e.el.contains(el) || el.contains(e.el))) return false;
// Skip empty containers (must have some text content)
const txt = (el.innerText || '').trim();
return txt.length > 0;
});
// Deduplicate nested result-items: only keep outermost
const dedupedResults = [];
for (const el of resultItems) {
if (dedupedResults.some(o => o.contains(el))) continue;
// Remove anything we previously added that this one contains
for (let i = dedupedResults.length - 1; i >= 0; i--) {
if (el.contains(dedupedResults[i])) dedupedResults.splice(i, 1);
}
dedupedResults.push(el);
}
// ── 1. emptyStateMessageWithVisibleResults ──────────────────────────
if (emptyStateEls.length >= 1 && dedupedResults.length >= 1) {
const msg = emptyStateEls[0];
const sampleResult = dedupedResults[0];
const sampleText = (sampleResult.innerText || '').trim().slice(0, 40).replace(/\s+/g, ' ');
out.push({
issueType: 'emptyStateMessageWithVisibleResults', severity: 'high',
selector: sel(msg.el), bbox: bb(msg.el),
description: `Empty-state message "${msg.text.slice(0, 40)}" (${msg.kind}) is visible while ${dedupedResults.length} result item(s) remain rendered, e.g. "${sampleText}". After an empty-result response, the previous result list should clear OR the empty-state message should not fire. This is the stale-data-after-empty-filter bug. (Your Students filter screenshot.)`
});
}
// ── 2. paginationVisibleWithEmptyState ──────────────────────────────
if (emptyStateEls.length >= 1) {
const paginationSel = '.pagination, [class*="pagination"], [class*="Pagination"], nav[aria-label*="page" i], [role="navigation"][aria-label*="page" i]';
const paginations = [...document.querySelectorAll(paginationSel)]
.filter(visible)
.filter(p => {
const txt = (p.innerText || '').trim();
// Real pagination contains digit page-numbers or Next/Prev
return /\d/.test(txt) || /next|prev|previous|first|last|»|«/i.test(txt);
});
if (paginations.length >= 1) {
out.push({
issueType: 'paginationVisibleWithEmptyState', severity: 'medium',
selector: sel(paginations[0]), bbox: bb(paginations[0]),
description: `Pagination is visible while an empty-state message ("${emptyStateEls[0].text.slice(0, 30)}") is also shown. Pagination should hide when there are no results. (Your Students screenshot: "1" page indicator with "Record Not Found" toast.)`
});
}
}
// ── 3. duplicateToastSimultaneous ───────────────────────────────────
if (toasts.length >= 2) {
const textBuckets = new Map();
for (const t of toasts) {
const txt = (t.innerText || '').trim().replace(/\s+/g, ' ').slice(0, 80);
if (txt.length < 3) continue;
// Normalize: drop trailing × / close-button artifacts
const norm = txt.replace(/\s*×\s*$/, '').replace(/\s*close\s*$/i, '').trim();
if (!textBuckets.has(norm)) textBuckets.set(norm, []);
textBuckets.get(norm).push(t);
}
let dupFlagged = 0;
for (const [txt, group] of textBuckets) {
if (dupFlagged >= 2) break;
if (group.length >= 2) {
dupFlagged++;
out.push({
issueType: 'duplicateToastSimultaneous', severity: 'medium',
selector: sel(group[0]), bbox: bb(group[0]),
description: `${group.length} simultaneous toasts share the same message: "${txt.slice(0, 50)}". The system fired the same notification multiple times. De-duplicate by text within a 1-2 second window, or suppress re-emit while the prior toast is still visible. (Your Students screenshot: two stacked "Record Not Found" toasts.)`
});
}
}
}
// ── 4. emptyStateInWrongLocation ────────────────────────────────────
// If the empty-state ONLY appears as a top-right toast and never inline in the result area
// when results genuinely should be empty, that's a UX miss — the result area should show its own empty state.
// Heuristic: empty-state toast present, no results visible, AND no inline empty-state in the main area.
if (emptyStateEls.length >= 1 && dedupedResults.length === 0) {
const onlyToast = emptyStateEls.every(e => e.kind === 'toast');
// Look for an inline empty-state UI inside the main area: an SVG/icon + heading saying "No data"
const inlineEmptyUI = main.querySelector(
'[class*="empty-state"], [class*="emptyState"], [class*="no-data"], [class*="noData"], [class*="no-results"]'
);
if (onlyToast && !inlineEmptyUI) {
const t = emptyStateEls[0];
out.push({
issueType: 'emptyStateInWrongLocation', severity: 'low',
selector: sel(t.el), bbox: bb(t.el),
description: `Empty-state appears only as a top-right toast ("${t.text.slice(0, 30)}") with no inline empty-state in the result area. Toasts disappear after a few seconds — users who miss them are left with a blank screen and no explanation. Show an inline "No records match these filters" message in the result area.`
});
}
}
return out;
}
Notes
- Self-skips: pages with no toasts AND no inline empty-state messages return []
- Bounded: 1 stale-results + 1 pagination + 2 duplicate toasts + 1 wrong-location = max ~5 findings per cell
- Catches your Students filter screenshot's three concurrent bugs: (a) stale result card, (b) pagination with no data, (c) two stacked duplicate toasts
- Complements
qa-detect-ux-toast-notification(which checks toast content quality) by focusing on toast vs page-state conflict - The
emptyStateMessageWithVisibleResultsis the highest-value: catches the actual stale-data bug class — users see a "no results" message but think the visible row IS the result