qa-detect-ux-active-state
Detects inconsistent active-state styling within the same navigation, tab group, or list — when one active item uses a background highlight and another uses only text color, users get confused about w
Files1
View on GitHubSKILL.mdskills/qa-detect-ux-active-state/SKILL.md
GitHubWhat it catches — 5 issue types
| issueType | severity | What |
|---|---|---|
activeStateMixedStyle |
medium | Same nav/menu has multiple "active" items styled differently — one with background highlight, another with only color/weight change (your sidebar: Human Resource = blue bg, Employee Attendance = blue text only) |
noActiveStateOnCurrentRoute |
medium | Page URL matches a nav item's href but the item has no visible active style (no .active class, no aria-current, no distinct bg/color) |
multipleActiveItemsInGroup |
medium | A nav group / tab list has 2+ items marked active simultaneously (.active or aria-selected="true") — only one should be active |
activeIndicatorTooSubtle |
low | Active item's background/border differs from siblings by less than 5% RGB delta — visually indistinguishable |
activeStateNoAriaCurrent |
low | Visually-active item has no aria-current attribute — screen reader users can't tell the current page |
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;
};
function parseRGB(s) {
if (!s) return null;
const m = s.match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\)/);
if (!m) return null;
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
function colorDelta(a, b) {
if (!a || !b) return 999;
return Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
}
const out = [];
// Find nav containers
const navContainers = document.querySelectorAll('nav, [role="navigation"], .sidebar, .side-nav, .nav-menu, [role="tablist"], .mat-tab-list, ul.nav, ul.menu, ul.list-group, .nav-list, [class*="sidenav"]');
for (const nav of [...navContainers].filter(visible).slice(0, 4)) {
// Get items
let items = [...nav.querySelectorAll('a[href], [role="tab"], [role="menuitem"], li, .nav-item, .menu-item, .sidebar-item')]
.filter(visible).slice(0, 25);
if (items.length < 3) continue;
// ── 1. Active-state mixed styles ─────────────────────────────────────
// Find all items that LOOK active (matching path OR having "active" class
// OR aria-selected/aria-current)
const activeItems = items.filter(it => {
const cls = (it.className || '').toString();
const ariaCurr = it.getAttribute('aria-current');
const ariaSel = it.getAttribute('aria-selected');
const hasActiveClass = /\b(active|selected|current|is-active|router-link-active|router-link-exact-active)\b/.test(cls);
return hasActiveClass || ariaCurr === 'page' || ariaCurr === 'true' || ariaSel === 'true';
});
if (activeItems.length >= 2) {
// Compare their visual style
const styles = activeItems.map(a => {
const cs = getComputedStyle(a);
return {
item: a,
bg: parseRGB(cs.backgroundColor),
color: parseRGB(cs.color),
fontWeight: cs.fontWeight,
borderColor: parseRGB(cs.borderLeftColor || cs.borderColor)
};
});
// Look at first vs others
for (let i = 1; i < styles.length; i++) {
const s0 = styles[0], si = styles[i];
const bgDelta = colorDelta(s0.bg, si.bg);
const colorDelta_ = colorDelta(s0.color, si.color);
// If backgrounds differ a lot OR weights differ a lot → mixed
if (bgDelta > 60 || (s0.fontWeight !== si.fontWeight && Math.abs(parseInt(s0.fontWeight) - parseInt(si.fontWeight)) > 100)) {
out.push({
issueType: 'activeStateMixedStyle', severity: 'medium',
selector: sel(nav), bbox: bb(nav),
description: `Nav has ${activeItems.length} items marked active but styled differently — first item bg ${s0.bg ? `rgb(${s0.bg.r},${s0.bg.g},${s0.bg.b})` : 'none'}, another item bg ${si.bg ? `rgb(${si.bg.r},${si.bg.g},${si.bg.b})` : 'none'}. Use ONE active style.`
});
break;
}
}
}
// ── 2. Multiple active items in same group ──────────────────────────
if (activeItems.length >= 2) {
// Check siblings only (same nav level)
const directSiblings = activeItems.filter(it => it.parentElement === activeItems[0].parentElement);
if (directSiblings.length >= 2) {
out.push({
issueType: 'multipleActiveItemsInGroup', severity: 'medium',
selector: sel(nav), bbox: bb(nav),
description: `${directSiblings.length} sibling nav items are marked active simultaneously. Only one item should be active in a group.`
});
}
}
// ── 3. Active indicator too subtle ──────────────────────────────────
if (activeItems.length >= 1 && items.length > activeItems.length) {
const active = activeItems[0];
const inactive = items.find(it => !activeItems.includes(it));
if (active && inactive) {
const aCs = getComputedStyle(active);
const iCs = getComputedStyle(inactive);
const bgDelta = colorDelta(parseRGB(aCs.backgroundColor), parseRGB(iCs.backgroundColor));
const colorDelta_ = colorDelta(parseRGB(aCs.color), parseRGB(iCs.color));
const weightDiff = Math.abs(parseInt(aCs.fontWeight) - parseInt(iCs.fontWeight));
if (bgDelta < 15 && colorDelta_ < 30 && weightDiff < 100) {
out.push({
issueType: 'activeIndicatorTooSubtle', severity: 'low',
selector: sel(active), bbox: bb(active),
description: `Active item differs from siblings by only ${bgDelta} bg / ${colorDelta_} color / ${weightDiff} weight delta. Visually indistinguishable from inactive items.`
});
}
}
}
// ── 4. Active item missing aria-current ─────────────────────────────
if (activeItems.length >= 1) {
const a = activeItems[0];
if (!a.getAttribute('aria-current') && !a.getAttribute('aria-selected')) {
out.push({
issueType: 'activeStateNoAriaCurrent', severity: 'low',
selector: sel(a), bbox: bb(a),
description: `Visually-active nav item has no aria-current/aria-selected. Screen readers can't tell which page is current.`
});
}
}
// ── 5. No active state for current route ────────────────────────────
const currentPath = location.pathname.toLowerCase();
const matchingItem = items.find(it => {
const a = it.tagName === 'A' ? it : it.querySelector('a[href]');
if (!a) return false;
const href = (a.getAttribute('href') || '').toLowerCase();
return href.length > 1 && (currentPath === href || currentPath.endsWith(href.replace(/^\/+/, '/')));
});
if (matchingItem && activeItems.length === 0) {
out.push({
issueType: 'noActiveStateOnCurrentRoute', severity: 'medium',
selector: sel(matchingItem), bbox: bb(matchingItem),
description: `Nav item matches current URL (${currentPath}) but no item in the menu has any active style. Users can't tell what page they're on.`
});
}
}
return out;
}
Notes
- Bounded: max ~10 findings per cell
- Self-skips: page with no nav containers returns []
- The
activeStateMixedStylecatches your sidebar's bug — Human Resource has a blue background while Employee Attendance has only blue text (two different "active" patterns in the same menu)