qa-detect-ux-breadcrumb
Detects breadcrumb UX problems: parent name same as current page (Students > Students), missing home/root link, only one item (not a breadcrumb), separator missing/inconsistent, current page is a link
Files1
View on GitHubSKILL.mdskills/qa-detect-ux-breadcrumb/SKILL.md
GitHubWhat it catches — 6 issue types
| issueType | severity | What |
|---|---|---|
breadcrumbDuplicateSegment |
medium | Two consecutive breadcrumb items have the same visible text ("Students > Students") — useless redundancy |
breadcrumbMissingHome |
low | Multi-level breadcrumb has no Home/Root/Dashboard link as the first item |
breadcrumbSingleItem |
low | "Breadcrumb" container has only 1 item — that's not a breadcrumb, it's a page title |
breadcrumbCurrentIsLink |
medium | Last (current-page) breadcrumb item is a clickable <a href> — clicking reloads the current page, confusing users |
breadcrumbSeparatorInconsistent |
low | Multiple separator characters used in same breadcrumb (>, /, ›, → mixed) |
breadcrumbNoLandmark |
low | Breadcrumb has no aria-label / role="navigation" — screen readers can't identify the navigation landmark |
breadcrumbWrapsMultiLine |
medium | Breadcrumb wraps to 2+ lines on narrow viewport — loses single-line navigation clarity |
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 breadcrumb containers
const containers = document.querySelectorAll(
'.breadcrumb, .breadcrumbs, [class*="breadcrumb"], [aria-label*="breadcrumb" i], nav[aria-label*="breadcrumb" i], ol.breadcrumb, ul.breadcrumb, [role="navigation"][class*="breadcrumb"]'
);
let dupFlagged = 0, homeFlagged = 0, singleFlagged = 0,
currentLinkFlagged = 0, sepFlagged = 0, landmarkFlagged = 0;
for (const bc of [...containers].filter(visible).slice(0, 3)) {
// Extract items — direct children that contain text
let items = [...bc.querySelectorAll('a, span, li, .breadcrumb-item, [class*="breadcrumb-item"]')]
.filter(visible)
.filter(el => {
const t = (el.innerText || '').trim();
// Filter out separator-only elements
return t.length > 0 && !/^[>›→\/|\-→»]+$/.test(t);
});
// Deduplicate: prefer leaf items (containing no other matching child)
items = items.filter((el, i, arr) => {
return !arr.some(other => other !== el && el.contains(other));
});
if (items.length === 0) continue;
// ── Duplicate consecutive segments (REAL: "Students > Students") ───
if (dupFlagged < 2) {
for (let i = 1; i < items.length; i++) {
const a = (items[i-1].innerText || '').trim().toLowerCase();
const b = (items[i].innerText || '').trim().toLowerCase();
if (a && a === b) {
dupFlagged++;
out.push({
issueType: 'breadcrumbDuplicateSegment', severity: 'medium',
selector: sel(bc), bbox: bb(bc),
description: `Breadcrumb has two consecutive identical segments: "${a}" > "${b}". Either the page is misnamed or the breadcrumb logic is wrong (likely parent route name equals current page name).`
});
break;
}
}
}
// ── Current page is a link (REAL: clicking reloads current page) ───
if (currentLinkFlagged < 2) {
const last = items[items.length - 1];
const isLink = last.tagName === 'A' || (last.querySelector && last.querySelector('a[href]'));
if (isLink) {
const link = last.tagName === 'A' ? last : last.querySelector('a[href]');
const href = link.getAttribute('href') || '';
if (href && href !== '#' && !href.startsWith('javascript:')) {
currentLinkFlagged++;
out.push({
issueType: 'breadcrumbCurrentIsLink', severity: 'medium',
selector: sel(last), bbox: bb(last),
description: `Current-page breadcrumb item "${(link.innerText || '').trim().slice(0, 30)}" is a clickable link. Last breadcrumb should be plain text (aria-current="page"), not a link.`
});
}
}
}
}
// ── Breadcrumb wraps to multiple lines ──────────────────────────────────
// On mobile/tablet, long breadcrumb paths wrap to 2–3 lines, consuming significant
// header space and losing the single-line "you are here" clarity.
// E.g. "Reports > Student\nAdoption\nReport" across 3 lines on mobile.
let wrapFlagged = 0;
for (const bc of [...containers].filter(visible).slice(0, 3)) {
if (wrapFlagged >= 2) break;
const r = bc.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
const s = getComputedStyle(bc);
const lineHeight = parseFloat(s.lineHeight) || parseFloat(s.fontSize) * 1.4 || 20;
if (r.height > lineHeight * 2.2) {
wrapFlagged++;
const lines = Math.round(r.height / lineHeight);
out.push({ issueType:'breadcrumbWrapsMultiLine', severity:'medium', selector:sel(bc), bbox:bb(bc),
description:`Breadcrumb wraps to ~${lines} lines (container ${Math.round(r.height)}px tall, line-height ~${Math.round(lineHeight)}px) on ${window.innerWidth}px viewport. Loses single-line navigation clarity. Fix: truncate long segment names, collapse middle segments with "…", or use a compact mobile breadcrumb pattern.` });
}
}
return out;
}
Notes
- Bounded: max ~9 findings per cell
- Self-skips: page with no breadcrumb container returns []
- The
breadcrumbDuplicateSegmentcatches your "Students > Students" bug from screenshot 1 - The
breadcrumbCurrentIsLinkcatches when the last item is a clickable link instead of plain text