qa-detect-ux-table-data

star 0

Detects table data-quality UX problems: excessive empty space below a small row count, reverse-ordered Sr#/ID columns (7 at top, 1 at bottom), columns that are empty in every visible row, sort indicators on only some columns. Catches the 'why does this table look broken' bug class.

Luqman-Ud-Din By Luqman-Ud-Din schedule Updated 6/4/2026

name: qa-detect-ux-table-data section: visual description: "Detects table data-quality UX problems: excessive empty space below a small row count, reverse-ordered Sr#/ID columns (7 at top, 1 at bottom), columns that are empty in every visible row, sort indicators on only some columns. Catches the 'why does this table look broken' bug class." model: haiku applyOn: all needsSetup: false viewportSensitive: false requires: [hasTables]

What it catches — 5 issue types

issueType severity What
tableExcessiveEmptySpace medium Table container has > 60% empty vertical space below the last visible row (table looks broken — sparse data in a large fixed-height container) — your Districts page bug
tableSerialColumnReversed medium Sr#/ID column shows descending numbers (7,6,5,...,1) when scrolled top-to-bottom. Users expect ascending in serial/index columns.
tableColumnAllEmpty low Visible column has no content (empty string / dash / placeholder) in every row across the visible range — your Clusters Description column
tableSortIndicatorInconsistent low Some column headers have sort indicators (↑↓ icons), others don't — users assume non-indicator columns aren't sortable when they actually are (or vice versa)
tableActionButtonsTooClose medium Edit/Delete action icons in same row are < 8px apart — misclick risk

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 = [];

  const tables = [...document.querySelectorAll('table, [role="table"], [role="grid"]')].filter(visible);
  let emptySpaceFlagged = 0, reverseFlagged = 0, emptyColFlagged = 0,
      sortFlagged = 0, actionFlagged = 0;

  for (const tbl of tables.slice(0, 4)) {
    const bodyRows = [...tbl.querySelectorAll('tbody tr, [role="rowgroup"]:not(:first-child) [role="row"]')].filter(visible);
    const headerCells = [...tbl.querySelectorAll('thead th, thead [role="columnheader"], [role="row"]:first-child > [role="columnheader"]')].filter(visible);

    // ── 1. Excessive empty space below table content ─────────────────────
    if (emptySpaceFlagged < 2 && bodyRows.length > 0) {
      // Find the container wrapper that the table lives in
      const wrap = tbl.closest('.table-wrapper, .table-container, .table-responsive, .data-table, .mat-table-container, .card-body, [class*="table-wrap"]') || tbl.parentElement;
      if (wrap && visible(wrap)) {
        const wrapR = wrap.getBoundingClientRect();
        const lastRow = bodyRows[bodyRows.length - 1];
        const lastR = lastRow.getBoundingClientRect();
        const usedHeight = lastR.bottom - wrapR.top;
        const totalHeight = wrapR.height;
        if (totalHeight > 200 && usedHeight > 0) {
          const emptyRatio = (totalHeight - usedHeight) / totalHeight;
          // Excessive: > 60% empty AND empty area > 200px
          const emptyPx = totalHeight - usedHeight;
          if (emptyRatio > 0.6 && emptyPx > 200 && bodyRows.length <= 5) {
            emptySpaceFlagged++;
            out.push({
              issueType: 'tableExcessiveEmptySpace', severity: 'medium',
              selector: sel(wrap), bbox: bb(wrap),
              description: `Table container is ${Math.round(totalHeight)}px tall but only ${Math.round(usedHeight)}px used (${Math.round(emptyPx)}px empty, ${Math.round(emptyRatio*100)}%). Either shrink container to fit content, or fill with placeholder/empty state.`
            });
          }
        }
      }
    }

    // ── 2. Sr#/ID column reverse-ordered ─────────────────────────────────
    if (reverseFlagged < 2 && bodyRows.length >= 3 && headerCells.length > 0) {
      // Find an index/serial column by header text
      let serialColIdx = -1;
      for (let i = 0; i < headerCells.length; i++) {
        const txt = (headerCells[i].innerText || '').trim().toLowerCase();
        if (/^(sr\.?#?|s\.?no|serial|#|id|index)$/i.test(txt) || /^(sr\.?\s*#|s\.?\s*no\.?)$/i.test(txt)) {
          serialColIdx = i;
          break;
        }
      }
      if (serialColIdx >= 0) {
        const numbers = [];
        for (const row of bodyRows.slice(0, 10)) {
          const cells = [...row.querySelectorAll('td, [role="cell"]')];
          if (!cells[serialColIdx]) continue;
          const n = parseInt((cells[serialColIdx].innerText || '').trim(), 10);
          if (Number.isFinite(n)) numbers.push(n);
        }
        if (numbers.length >= 3) {
          // Check descending: every pair n[i] > n[i+1]
          let descending = true, ascending = true;
          for (let i = 1; i < numbers.length; i++) {
            if (numbers[i] >= numbers[i-1]) descending = false;
            if (numbers[i] <= numbers[i-1]) ascending = false;
          }
          if (descending && !ascending) {
            reverseFlagged++;
            out.push({
              issueType: 'tableSerialColumnReversed', severity: 'medium',
              selector: sel(headerCells[serialColIdx]), bbox: bb(headerCells[serialColIdx]),
              description: `Sr#/ID column shows descending values (${numbers.slice(0, 5).join(', ')}...). Users expect ascending (1, 2, 3,…) when scrolling top-to-bottom. Reverse the sort or remove the serial column.`
            });
          }
        }
      }
    }

    // ── 3. Visible column has no content in any row ──────────────────────
    if (emptyColFlagged < 3 && bodyRows.length >= 3 && headerCells.length > 0) {
      for (let colIdx = 0; colIdx < headerCells.length; colIdx++) {
        if (emptyColFlagged >= 3) break;
        const hdrTxt = (headerCells[colIdx].innerText || '').trim().toLowerCase();
        if (/^(actions?|operations?|edit|delete)$/i.test(hdrTxt)) continue;   // action columns naturally have icons not text
        // Sample up to 8 rows
        let emptyCount = 0, sampled = 0;
        for (const row of bodyRows.slice(0, 8)) {
          const cells = [...row.querySelectorAll('td, [role="cell"]')];
          if (!cells[colIdx]) continue;
          sampled++;
          const cellTxt = (cells[colIdx].innerText || '').replace(/\s+/g, '').trim();
          const hasContent = cellTxt.length > 0 && cellTxt !== '-' && cellTxt !== '—' && cellTxt !== 'N/A' && cellTxt !== '--';
          const hasIcon = !!cells[colIdx].querySelector('svg, img, i.fa, i.material-icons');
          if (!hasContent && !hasIcon) emptyCount++;
        }
        if (sampled >= 3 && emptyCount === sampled) {
          emptyColFlagged++;
          out.push({
            issueType: 'tableColumnAllEmpty', severity: 'low',
            selector: sel(headerCells[colIdx]), bbox: bb(headerCells[colIdx]),
            description: `Column "${hdrTxt || 'unnamed'}" is empty in all ${sampled} visible rows. Either populate it, show a placeholder (dash), or remove the column.`
          });
        }
      }
    }

    // ── 4. Sort indicator inconsistency ──────────────────────────────────
    if (sortFlagged < 2 && headerCells.length >= 3) {
      let withIndicator = 0, withoutIndicator = 0;
      for (const h of headerCells) {
        const hasSortIcon = !!h.querySelector('svg, [class*="sort"], [class*="arrow"], .mat-sort-header-arrow');
        const ariaSort = h.getAttribute('aria-sort');
        const dataSort = h.getAttribute('data-sortable') || h.getAttribute('data-sort');
        const textChevron = /[▲▼↑↓↕⇅⬍]/.test((h.innerText || ''));
        if (hasSortIcon || ariaSort || dataSort === 'true' || dataSort === '' || textChevron) {
          withIndicator++;
        } else {
          // Exclude Actions / Status columns by name
          const t = (h.innerText || '').trim().toLowerCase();
          if (!/^(actions?|operations?|status)$/i.test(t)) withoutIndicator++;
        }
      }
      if (withIndicator >= 2 && withoutIndicator >= 1) {
        sortFlagged++;
        out.push({
          issueType: 'tableSortIndicatorInconsistent', severity: 'low',
          selector: sel(tbl), bbox: bb(tbl),
          description: `${withIndicator} columns have sort indicators, ${withoutIndicator} similar data columns don't. Mark all sortable columns OR add aria-sort="none" to unsortable ones — currently ambiguous.`
        });
      }
    }

    // ── 5. Action buttons too close (misclick risk) ──────────────────────
    if (actionFlagged < 3 && bodyRows.length > 0) {
      for (const row of bodyRows.slice(0, 5)) {
        if (actionFlagged >= 3) break;
        const actions = [...row.querySelectorAll('button, a, [role="button"], svg[onclick], i[onclick]')]
          .filter(visible);
        if (actions.length < 2) continue;
        // Check pairs in the same row
        for (let i = 1; i < actions.length; i++) {
          const a = actions[i-1].getBoundingClientRect();
          const b = actions[i].getBoundingClientRect();
          if (Math.abs(a.top - b.top) > 12) continue;   // not same line
          const gap = b.left - a.right;
          if (gap >= 0 && gap < 8) {
            actionFlagged++;
            out.push({
              issueType: 'tableActionButtonsTooClose', severity: 'medium',
              selector: sel(row), bbox: bb(row),
              description: `Adjacent action controls in same row are ${gap.toFixed(0)}px apart. Misclick risk — increase gap to ≥ 8px (WCAG 2.5.5 spacing).`
            });
            break;
          }
        }
      }
    }
  }

  return out;
}

Notes

  • Bounded: 2 empty-space + 2 reverse + 3 empty-col + 2 sort + 3 action-gap = max ~12 findings
  • Self-skips: page with no tables returns []
  • The tableExcessiveEmptySpace is the EXACT bug from your Districts screenshot (1 row, hundreds of px of dead space below)
  • The tableSerialColumnReversed is the EXACT bug from your Clusters screenshot (Sr# = 7 at top descending to 1 at bottom)
  • The tableColumnAllEmpty catches the Description column being blank for all rows in your Clusters page
Install via CLI
npx skills add https://github.com/Luqman-Ud-Din/blackbox-qa-agent --skill qa-detect-ux-table-data
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
Luqman-Ud-Din
Luqman-Ud-Din Explore all skills →