qa-detect-ux-chart-render
Detects broken chart rendering — chart container has scaffolding (axes, gridlines, legend) but no actual data marks (no bars, lines, points); orphan tooltips floating with no data point beneath them;
SKILL.mdskills/qa-detect-ux-chart-render/SKILL.md
GitHubWhat it catches — 4 issue types
| issueType | severity | What |
|---|---|---|
chartEmptyNoData |
high | Chart container (Recharts/ApexCharts/Chart.js/ECharts/Highcharts/D3) has axes, gridlines, and legend rendered but ZERO data shapes (no <path d="M…L…">, no <rect> bars, no <circle> markers, no canvas pixel variance) |
chartTooltipOrphan |
medium | A chart tooltip / hover popover is visibly rendered with no data point beneath its arrow / no active marker — leaks UI state (your Fees & Expenses "Nov" floating gray box) |
chartAxisLabelsButNoMarks |
medium | Chart has visible Y-axis numbers (e.g. "1, 1.5, 2") but the plot area has no rendered data marks in those rows |
chartLegendButNoSeries |
low | Legend lists series ("Fees", "Expenses") but the chart contains no <path> / <g class*="series"> matching those names — data series silently absent |
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 = [];
// Find chart containers — common libraries first, then generic
const chartSelectors = [
'.recharts-wrapper', '.recharts-surface',
'.apexcharts-canvas', '[id^="apexcharts"]',
'canvas.chartjs-render-monitor', 'canvas[class*="chart"]',
'.echarts-for-react', '[_echarts_instance_]',
'.highcharts-container',
'svg.chart, svg[class*="chart"]',
'[class*="chart-container"]', '[class*="chartContainer"]',
'[class*="line-chart"]', '[class*="bar-chart"]', '[class*="pie-chart"]', '[class*="area-chart"]'
];
const seen = new Set();
const charts = [];
for (const s of chartSelectors) {
for (const el of document.querySelectorAll(s)) {
if (seen.has(el)) continue;
// Skip nested matches — only keep outermost
if ([...seen].some(o => o.contains(el))) continue;
seen.add(el);
if (visible(el) && el.getBoundingClientRect().width >= 120) charts.push(el);
}
}
if (charts.length === 0) return out;
let emptyFlagged = 0;
let orphanFlagged = 0;
let axisOnlyFlagged = 0;
let legendOnlyFlagged = 0;
for (const chart of charts) {
if (emptyFlagged >= 3 && orphanFlagged >= 2 && axisOnlyFlagged >= 2 && legendOnlyFlagged >= 2) break;
const cr = chart.getBoundingClientRect();
// ── 1. chartEmptyNoData — count data marks vs scaffolding ─────────
// Data marks: <path d="..."> in plot area, <rect> bars, <circle> with r>1.5
// Scaffolding: axis lines, gridlines, legend text, axis text, ticks
//
// IMPORTANT: smooth/curved line charts (Recharts default, Chart.js, ApexCharts)
// emit paths using cubic-bezier (C), quadratic-bezier (Q), and smooth shorthand
// (S, T) commands, NOT just M+L. Count ALL drawing operators to avoid false
// "chart empty" findings on curved-line charts.
const allPaths = chart.querySelectorAll('path');
let dataPathCount = 0;
for (const p of allPaths) {
const cls = (p.getAttribute('class') || '').toLowerCase();
const d = (p.getAttribute('d') || '');
// Skip explicit grid/axis/tick paths
if (/grid|axis|tick|cartesian|baseline|domain/.test(cls)) continue;
// Count ALL path drawing commands: M/L/H/V/C/S/Q/T/A (move, line, curve, arc)
// — a line chart curve like "M65,242C110,242,...C155,232,..." has 1 M + N C commands.
const segCount = (d.match(/[MLHVCSQTA]/gi) || []).length;
if (segCount >= 3 && d.length >= 8) dataPathCount++;
}
const allRects = chart.querySelectorAll('rect');
let dataRectCount = 0;
for (const r of allRects) {
const cls = (r.getAttribute('class') || '').toLowerCase();
if (/grid|axis|background|brush|panel|tooltip/.test(cls)) continue;
const w = parseFloat(r.getAttribute('width')) || 0;
const h = parseFloat(r.getAttribute('height')) || 0;
// Bar marks are usually width<100 and height>3, NOT spanning the whole chart
if (w > 0 && h > 3 && w < cr.width * 0.5 && h < cr.height * 0.95) dataRectCount++;
}
const allCircles = chart.querySelectorAll('circle');
let dataCircleCount = 0;
for (const c of allCircles) {
const r = parseFloat(c.getAttribute('r')) || 0;
if (r > 1.5 && r < 30) dataCircleCount++;
}
const dataMarks = dataPathCount + dataRectCount + dataCircleCount;
// Detect scaffolding presence
const hasAxis = !!chart.querySelector('[class*="axis"], [class*="Axis"], g.tick, .recharts-cartesian-axis, .apexcharts-yaxis, .apexcharts-xaxis, .echarts-axis');
const hasGrid = !!chart.querySelector('[class*="grid"], [class*="Grid"], .recharts-cartesian-grid, .apexcharts-gridline, .echarts-grid, line[stroke-dasharray]');
const axisTextEls = [...chart.querySelectorAll('text, tspan, [class*="axis-label"], [class*="axisLabel"]')]
.filter(visible)
.filter(el => (el.textContent || '').trim().length > 0);
const hasAxisText = axisTextEls.length >= 2;
if (dataMarks === 0 && (hasAxis || hasGrid || hasAxisText) && emptyFlagged < 3) {
emptyFlagged++;
out.push({
issueType: 'chartEmptyNoData', severity: 'high',
selector: sel(chart), bbox: bb(chart),
description: `Chart ${cr.width.toFixed(0)}×${cr.height.toFixed(0)}px has scaffolding (${hasAxis ? 'axis ' : ''}${hasGrid ? 'gridlines ' : ''}${hasAxisText ? `${axisTextEls.length} axis labels` : ''}) but ZERO data marks (no data paths/bars/points). Chart loaded into the DOM but data is empty or failed to render. Show an explicit empty-state message ("No data for this period") instead of an empty chart skeleton.`
});
continue;
}
// ── 3. chartAxisLabelsButNoMarks — axis labels present, plot area empty ──
if (dataMarks === 0 && hasAxisText && axisTextEls.length >= 3 && axisOnlyFlagged < 2) {
const labels = axisTextEls.slice(0, 4).map(e => (e.textContent || '').trim()).filter(Boolean);
axisOnlyFlagged++;
out.push({
issueType: 'chartAxisLabelsButNoMarks', severity: 'medium',
selector: sel(chart), bbox: bb(chart),
description: `Chart shows axis labels (${labels.slice(0, 3).join(', ')}) but the plot area is blank. Either the data is missing or the rendering pipeline failed silently.`
});
}
// ── 4. chartLegendButNoSeries — legend lists series but no <path>/<g> for them ──
const legendItems = [...chart.querySelectorAll('[class*="legend"] text, [class*="legend"] span, [class*="Legend"] text, [class*="Legend"] span, .apexcharts-legend-text')]
.filter(visible)
.map(e => (e.textContent || '').trim())
.filter(t => t.length > 0 && t.length < 40);
if (dataMarks === 0 && legendItems.length >= 1 && legendOnlyFlagged < 2) {
legendOnlyFlagged++;
out.push({
issueType: 'chartLegendButNoSeries', severity: 'low',
selector: sel(chart), bbox: bb(chart),
description: `Legend lists series (${legendItems.slice(0, 3).join(', ')}) but no data series are rendered in the chart. Either hide the legend when data is empty or show an empty state.`
});
}
}
// ── 2. chartTooltipOrphan — visible tooltip with no active marker beneath ───
const tooltipSelectors = '[class*="tooltip"], [role="tooltip"], .recharts-tooltip-wrapper, .apexcharts-tooltip, .echarts-tooltip';
const tips = [...document.querySelectorAll(tooltipSelectors)].filter(visible);
for (const tip of tips) {
if (orphanFlagged >= 2) break;
const tr = tip.getBoundingClientRect();
if (tr.width < 20 || tr.height < 12) continue;
// Find the chart this tooltip belongs to
const chart = charts.find(c => {
const cr = c.getBoundingClientRect();
// Tooltip overlaps the chart's bounding region
return tr.left < cr.right + 100 && tr.right > cr.left - 100 &&
tr.top < cr.bottom + 100 && tr.bottom > cr.top - 100;
});
if (!chart) continue;
// Is there an "active" marker inside the chart? (Recharts uses .recharts-active-dot; ApexCharts marks active series)
const activeMarker = chart.querySelector(
'.recharts-active-dot, .recharts-tooltip-cursor, ' +
'.apexcharts-tooltip-marker.apexcharts-active, .apexcharts-active-marker, ' +
'circle[r="6"], circle[r="8"], [class*="active-dot"], [class*="activeDot"]'
);
if (activeMarker && visible(activeMarker)) continue;
// No active marker — tooltip is orphan
orphanFlagged++;
out.push({
issueType: 'chartTooltipOrphan', severity: 'medium',
selector: sel(tip), bbox: bb(tip),
description: `Chart tooltip "${(tip.innerText || '').trim().slice(0, 30)}" is visible at (${Math.round(tr.left)}, ${Math.round(tr.top)}) with no active data marker beneath it. Tooltip should hide when no hover target exists. Likely a leftover state from initial render or chart re-render.`
});
}
return out;
}
Notes
- Self-skips: pages with no chart containers return [] immediately
- Bounded: max 3 empty + 2 orphan + 2 axisOnly + 2 legendOnly = ~9 findings per cell
- Covers Recharts, ApexCharts, Chart.js (canvas), ECharts, Highcharts, and generic SVG charts
- The
chartEmptyNoDatacheck is the highest-value: catches the "chart skeleton with no data" pattern (your Fees & Expenses screenshot) - For canvas-based charts (Chart.js), the probe can't see pixels; we infer empty by absence of data-driven inline styles and by chart wrappers that haven't injected
<canvas>with width/height