qa-detect-images
Detects missing alt text, broken images, oversized images, aspect-ratio distortion (stretched), and hero/banner images without object-fit:cover
Files2
View on GitHubSKILL.mdskills/qa-detect-images/SKILL.md
GitHubWhat it checks
- Missing alt attribute (absent, not empty)
- Failed loads (broken image)
- Images rendered at <1/3 natural size (oversized download for tiny render)
- Images stretched: rendered aspect ratio differs from natural aspect ratio by >20%
- Hero/banner images without
object-fit: cover— renders as distorted fill or letterboxed contain
Probe (browser_evaluate)
() => {
const out = [];
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) }; };
// Returns true only if the element is actually rendered and visible to the user.
// Skips images inside collapsed sidebars, hidden navs, display:none containers,
// and elements parked entirely off-screen (negative coords / beyond the viewport).
const vw = innerWidth, vh = innerHeight;
const isRendered = el => {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
// Entirely outside the viewport box → user never sees it (e.g. collapsed sidebar
// logo at x:-228, or off-canvas drawer image past the right edge).
if (r.right <= 0 || r.left >= vw || r.bottom <= 0 || r.top >= vh) return false;
let node = el;
while (node && node !== document.documentElement) {
const s = getComputedStyle(node);
if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') return false;
node = node.parentElement;
}
return true;
};
// Returns true for small UI icons that are purely decorative alongside visible text.
// These should have alt="" but are not content-carrying images — flagging them as
// missingAlt creates false-positive noise (user sees no "image", just an icon).
const isDecorativeIcon = (img) => {
const r = img.getBoundingClientRect();
// Only applies to small icons (≤ 40×40px render size)
if (r.width > 40 || r.height > 40) return false;
// Explicitly marked decorative by the developer → fully skip, no issue at all
if (img.getAttribute('aria-hidden') === 'true' || img.getAttribute('role') === 'presentation') return true;
// Icon-like src filename hint (icon, glyph, sprite, badge, etc.)
const src = (img.src || '').toLowerCase();
if (/\b(icon|glyph|sprite|badge|avatar|favicon|logo)\b/.test(src)) return true;
// Small image inside a card-header, stat-widget, nav, topbar, or sidebar
// where sibling/parent visible text already carries the semantic meaning
const decorativeContainer = img.closest(
'nav, [class*="sidebar"], [class*="navbar"], [class*="topbar"], [class*="appbar"],' +
'[class*="card-header"], [class*="card-icon"], [class*="stat"], [class*="widget-header"],' +
'[class*="menu-item"], [class*="nav-item"], [class*="breadcrumb"]'
);
if (decorativeContainer) {
// Icon is alongside text — it's decorative in this context
const parent = img.parentElement;
if (parent) {
const hasNearbyText = parent.innerText && parent.innerText.trim().length > 0;
if (hasNearbyText) return true;
}
}
return false;
};
for (const img of document.querySelectorAll('img')) {
if (out.length >= 20) break;
const src = (img.src || '').slice(0,100);
const selector = `img[src="${src.slice(0,60)}"]`;
// 1. Missing alt — only on rendered, non-decorative images.
// Icons alongside text labels are decorative — they need alt="" not descriptive alt,
// and are filed at low severity with a targeted fix message to avoid false-positive noise.
if (!img.hasAttribute('alt') && isRendered(img)) {
// Completely skip if aria-hidden or role=presentation (developer already handled it)
if (img.getAttribute('aria-hidden') === 'true' || img.getAttribute('role') === 'presentation') {
// no issue
} else if (isDecorativeIcon(img)) {
// Small icon next to visible text — needs alt="" (empty) not descriptive alt
out.push({ issueType:'decorativeIconMissingEmptyAlt', severity:'low', selector,
description:`Small icon <img> (${Math.round(img.getBoundingClientRect().width)}×${Math.round(img.getBoundingClientRect().height)}px) is missing alt="". Since it appears alongside a visible text label it is decorative — add alt="" or aria-hidden="true" to silence screen readers.`, bbox: bb(img) });
} else {
// Real content image — needs a meaningful description
out.push({ issueType:'missingAlt', severity:'medium', selector,
description:`<img> element is missing the alt attribute entirely. Add descriptive alt text so screen-reader users know what the image conveys.`, bbox: bb(img) });
}
}
// 2. Broken image — only flag if the image is actually rendered/visible.
// Hidden images (inside collapsed sidebar, display:none nav, etc.) are NOT flagged here —
// they may be intentional placeholders or off-screen variants never shown to the user.
if (img.complete && img.naturalWidth === 0) {
if (!isRendered(img)) continue; // collapsed sidebar, hidden panel, off-screen — skip
out.push({ issueType:'brokenImage', severity:'critical', selector,
description:`Image failed to load: ${src}`, bbox: bb(img) });
continue; // can't compute ratios without natural dimensions
}
const r = img.getBoundingClientRect();
if (img.naturalWidth === 0 || img.naturalHeight === 0) continue;
if (r.width === 0 || r.height === 0) continue;
if (!isRendered(img)) continue; // off-screen / hidden — oversized/stretched are irrelevant if unseen
// 3. Oversized (downloaded much larger than rendered)
if (img.naturalWidth/r.width > 3) {
const ratio = (img.naturalWidth/r.width).toFixed(1);
out.push({ issueType:'oversizedImage', severity:'low', selector,
description:`Natural size ${img.naturalWidth}×${img.naturalHeight}px rendered at ${Math.round(r.width)}×${Math.round(r.height)}px (${ratio}x oversized)`, bbox: bb(img) });
}
// 4. Stretched (aspect ratio distortion)
const naturalRatio = img.naturalWidth / img.naturalHeight;
const renderedRatio = r.width / r.height;
const ratioDiff = Math.abs(naturalRatio - renderedRatio) / naturalRatio;
if (ratioDiff > 0.20) {
const pctOff = Math.round(ratioDiff * 100);
out.push({ issueType:'imageStretched', severity:'medium', selector,
description:`Aspect ratio distorted: natural ${img.naturalWidth}×${img.naturalHeight} (${naturalRatio.toFixed(2)}:1) rendered at ${Math.round(r.width)}×${Math.round(r.height)} (${renderedRatio.toFixed(2)}:1) — ${pctOff}% off`, bbox: bb(img) });
}
}
// 6. CSS background-image hero sections — background-size not cover
const heroContainerSelectors = [
'[class*="hero"]','[class*="banner"]','[class*="jumbotron"]','[class*="masthead"]',
'[class*="splash"]','header[class*="bg"]','section[class*="cover"]'
];
const seenBgEls = new Set();
for (const hSel of heroContainerSelectors) {
for (const el of document.querySelectorAll(hSel)) {
if (out.length >= 20 || seenBgEls.has(el)) break;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height < 150) continue;
const s = getComputedStyle(el);
const bgImage = s.backgroundImage;
if (!bgImage || bgImage === 'none' || !bgImage.startsWith('url')) continue;
seenBgEls.add(el);
const bgSize = s.backgroundSize;
if (bgSize === 'cover') continue; // correct
const selector = `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : (el.className ? '.' + String(el.className).trim().split(/\s+/)[0] : '')}`.slice(0, 120);
out.push({ issueType: 'heroBackgroundNoCover', severity: 'medium', selector,
description: `Hero/banner section (${Math.round(r.width)}×${Math.round(r.height)}px) has background-image with background-size:${bgSize || 'auto'} — use background-size:cover to prevent letterboxing or distortion on different screen sizes`,
bbox: bb(el) });
}
}
// 5. Hero image object-fit — large/banner images without object-fit:cover
//
// CRITICAL: a "hero" image must actually be hero-sized AND not a logo. The
// previous selector `header img` matched ANY <img> inside ANY <header>,
// including site logos in sidebar/topbar headers. We now require BOTH a
// hero-like ancestor class AND minimum size, OR size-only for unclassed cases,
// AND skip anything that smells like a logo/brand glyph.
const isLogoOrBrand = (img) => {
const alt = (img.getAttribute('alt') || '').toLowerCase();
if (/^(logo|brand|favicon|icon|avatar|profile)\b/.test(alt)) return true;
const cls = ((img.className && typeof img.className === 'string') ? img.className : '').toLowerCase();
if (/\b(logo|brand|favicon|avatar)\b/.test(cls)) return true;
// Image inside a logo/brand/avatar/nav-icon container
const container = img.closest(
'[class*="logo"], [class*="Logo"], [class*="brand"], [class*="Brand"], ' +
'[class*="avatar"], [class*="Avatar"], [class*="profile-pic"], [class*="favicon"], ' +
'nav, aside, [class*="sidebar"], [class*="Sidebar"], [class*="topbar"], [class*="Topbar"], ' +
'[class*="navbar"], [class*="NavBar"], [class*="appbar"], [class*="AppBar"]'
);
if (container) return true;
// src filename hints
const src = (img.src || '').toLowerCase();
if (/(logo|brand|favicon|avatar|profile)[-_]?\w*\.(png|jpe?g|svg|webp)/.test(src)) return true;
return false;
};
const heroImgs = new Set();
// Class-based heroes — but still must be a real-sized image (not a 40px logo
// inside a `<header class="page-header">`).
const heroSelectors = [
'[class*="hero"] img', '[class*="banner"] img', '[class*="jumbotron"] img',
'[class*="masthead"] img', '[class*="splash"] img'
];
for (const hSel of heroSelectors) {
for (const hImg of document.querySelectorAll(hSel)) {
const r = hImg.getBoundingClientRect();
// Must be at least 40% viewport width AND 120px tall to qualify as hero
if (r.width >= innerWidth * 0.4 && r.height >= 120) heroImgs.add(hImg);
}
}
// Size-only fallback for unclassed pages: ≥60% viewport width AND ≥200px tall
for (const img of document.querySelectorAll('img')) {
const r = img.getBoundingClientRect();
if (r.width >= innerWidth * 0.6 && r.height >= 200) heroImgs.add(img);
}
for (const img of heroImgs) {
if (out.length >= 20) break;
if (isLogoOrBrand(img)) continue; // skip logos / avatars / nav icons
if (img.complete && img.naturalWidth === 0) continue; // already caught as broken
const r = img.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
const s = getComputedStyle(img);
const objectFit = s.objectFit || 'fill';
const src = (img.src || '').slice(0, 100);
const heroSel = `img[src="${src.slice(0, 60)}"]`;
if (objectFit === 'fill' || objectFit === 'none') {
out.push({ issueType: 'heroImageNoCover', severity: 'medium', selector: heroSel,
description: `Hero/banner image (${Math.round(r.width)}×${Math.round(r.height)}px) uses object-fit:${objectFit} — will distort on different aspect-ratio screens. Use object-fit:cover.`, bbox: bb(img) });
} else if (objectFit === 'contain') {
out.push({ issueType: 'heroImageLetterboxed', severity: 'low', selector: heroSel,
description: `Hero/banner image (${Math.round(r.width)}×${Math.round(r.height)}px) uses object-fit:contain — may show empty letterbox bars on different screens. Consider object-fit:cover with object-position.`, bbox: bb(img) });
}
}
return out;
}
Issues
| issueType | severity | description |
|---|---|---|
| missingAlt | medium | "img element is missing the alt attribute entirely" |
| brokenImage | critical | "Image failed to load: {src}" |
| oversizedImage | low | "Natural size {nw}×{nh}px rendered at {rw}×{rh}px ({ratio}x oversized)" |
| imageStretched | medium | "Aspect ratio distorted: natural {nw}×{nh} ({nr}:1) rendered at {rw}×{rh} ({rr}:1) — {pct}% off" |
| heroImageNoCover | medium | "Hero/banner image uses object-fit:{fill |
| heroImageLetterboxed | low | "Hero/banner image uses object-fit:contain — may show empty letterbox bars on different screens" |
| heroBackgroundNoCover | medium | "Hero/banner section has background-image with background-size:{size} — use background-size:cover" |
Threshold note
The 20% threshold balances catching genuine distortion (logos squashed, photos stretched) against allowing minor variations from object-fit: cover cropping. Increase to 30% if false positives appear on photos with intentional cropping; decrease to 10% for strict design QA.