detector: narrow the tab-strip stripe exemption to actual selection state

Tab-strip MEMBERSHIP no longer exempts chromatic top/bottom stripes —
only a real selection marker does: aria-selected="true", aria-current
(any non-false value), or an active/current/selected class hint. A
stripe repeated on every tab in the group ([role=tab], .tabs items,
aria-selected="false" tabs) is decoration and flags as side-tab; the
selected tab's own underline — including the reserved-space
transparent-border pattern — stays legal. Applied consistently across
the element border path (isTabContextElement), the pseudo-element
stripe scan, and the inset box-shadow stripe scan.

Also replaces a stray NUL byte in the marquee scanner's dedupe key
that made tools treat checks.mjs as binary.

Browser bundle regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-12 20:04:22 -07:00
co-authored by Claude Fable 5
parent 5e1925f9d2
commit b6304913ef
3 changed files with 117 additions and 41 deletions
+27 -18
View File
@@ -1441,12 +1441,17 @@ function scanCssTextForPseudoStripe(content) {
decls.get('height') || decls.get('block-size') || '', customProps));
const verticalCandidate = widthPx != null && widthPx >= 3 && widthPx <= 12;
// Horizontal variant (top/bottom stripe) carries extra exemptions:
// link/button underline affordances, tab strips, selected states, and
// state-conditional (:hover/:focus/...) affordances are not stripes.
// link/button underline affordances, selected-state indicators
// (aria-selected="true", aria-current, active/current/selected class
// hints), and state-conditional (:hover/:focus/...) affordances are
// not stripes. Tab-strip membership alone ([role=tab], .tabs, bare
// [aria-selected]) is NOT exempt — a stripe on every tab in the
// group is decoration; only the selected item's underline stays.
const horizontalCandidate = heightPx != null && heightPx >= 3 && heightPx <= 12
&& !/(?:^|[\s>+~,(])(?:a|button|summary|tr|td|th|table|li)(?![\w-])/i.test(selector)
&& !/\[role=["']?tab|\[aria-selected/i.test(selector)
&& !/(?:^|[\s._[-])(?:tabs?|tablist|tab-[\w-]*|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector)
&& !/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)
&& !/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)
&& !/(?:^|[\s._[-])(?:active|current|selected|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector)
&& !/:(?:hover|focus|focus-visible|focus-within|active|checked)\b/i.test(selector);
if (!verticalCandidate && !horizontalCandidate) continue;
@@ -1539,12 +1544,14 @@ function scanCssTextForInsetStripe(content) {
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
// State/selection contexts: current-item markers, interaction states,
// explicit tab semantics.
// Selection-state contexts: current-item markers and interaction
// states. Tab-strip membership alone ([role=tab], .tabs, bare
// [aria-selected]) is NOT exempt — a stripe on every tab in the
// group is decoration; only the selected item's indicator stays.
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
if (/\[aria-(?:current|selected)/i.test(selector)) continue;
if (/\[role=["']?tab/i.test(selector)) continue;
if (/(?:^|[\s._[-])(?:active|current|selected|tabs?)(?![\w])/i.test(selector)) continue;
if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
// Structural tags where a single-edge inset shadow is depth/quoting,
// not an accent stripe.
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
@@ -1646,7 +1653,7 @@ function scanCssTextForMarquee(content) {
const decls = parseCssDeclBlock(m[2]);
for (const name of infiniteAnimationNames(decls)) {
if (!marqueeKeyframes.has(name)) continue;
const key = `${selector}${name}`;
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
@@ -2181,21 +2188,23 @@ function resolveBorderRadiusPx(el, style, widthPx, win) {
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
// Selected-state / tab-strip context for accent stripes. Explicit tab
// semantics ([role=tablist]/[role=tab]) and active/current-item markers
// (aria-selected, aria-current, active/current/selected class hints)
// exempt the stripe as a selection indicator. A bare nav/menu ancestor
// deliberately does NOT — the same stripe repeated unconditionally on
// every menu item is decoration, not state.
// Selected-state context for accent stripes. Only an actual selection
// marker exempts the stripe as the standard active-item indicator:
// aria-selected="true", aria-current (any non-false value), or an
// active/current/selected class hint. Tab-strip MEMBERSHIP alone
// ([role=tablist]/[role=tab]/.tabs ancestry, aria-selected="false")
// deliberately does not — a chromatic stripe repeated on every tab in
// the group, or on every menu item, is decoration, not state; the
// selected item's own underline stays legal.
function isTabContextElement(el) {
if (!el) return false;
try {
if (el.closest?.('[role="tablist"], [role="tab"], [aria-selected], [aria-current]')) return true;
if (el.closest?.('[aria-selected="true"], [aria-current]:not([aria-current="false"])')) return true;
} catch { /* selector engine differences — fall through to class scan */ }
let cur = el, depth = 0;
while (cur && cur.nodeType === 1 && depth < 6) {
const cls = String(cur.getAttribute?.('class') || cur.className || '');
if (/(?:^|[\s_-])(?:tabs?|active|current|selected)(?:$|[\s_-])/i.test(cls)) return true;
if (/(?:^|[\s_-])(?:active|current|selected)(?:$|[\s_-])/i.test(cls)) return true;
cur = cur.parentElement;
depth++;
}
+27 -18
View File
@@ -740,12 +740,17 @@ function scanCssTextForPseudoStripe(content) {
decls.get('height') || decls.get('block-size') || '', customProps));
const verticalCandidate = widthPx != null && widthPx >= 3 && widthPx <= 12;
// Horizontal variant (top/bottom stripe) carries extra exemptions:
// link/button underline affordances, tab strips, selected states, and
// state-conditional (:hover/:focus/...) affordances are not stripes.
// link/button underline affordances, selected-state indicators
// (aria-selected="true", aria-current, active/current/selected class
// hints), and state-conditional (:hover/:focus/...) affordances are
// not stripes. Tab-strip membership alone ([role=tab], .tabs, bare
// [aria-selected]) is NOT exempt — a stripe on every tab in the
// group is decoration; only the selected item's underline stays.
const horizontalCandidate = heightPx != null && heightPx >= 3 && heightPx <= 12
&& !/(?:^|[\s>+~,(])(?:a|button|summary|tr|td|th|table|li)(?![\w-])/i.test(selector)
&& !/\[role=["']?tab|\[aria-selected/i.test(selector)
&& !/(?:^|[\s._[-])(?:tabs?|tablist|tab-[\w-]*|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector)
&& !/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)
&& !/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)
&& !/(?:^|[\s._[-])(?:active|current|selected|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector)
&& !/:(?:hover|focus|focus-visible|focus-within|active|checked)\b/i.test(selector);
if (!verticalCandidate && !horizontalCandidate) continue;
@@ -838,12 +843,14 @@ function scanCssTextForInsetStripe(content) {
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
// State/selection contexts: current-item markers, interaction states,
// explicit tab semantics.
// Selection-state contexts: current-item markers and interaction
// states. Tab-strip membership alone ([role=tab], .tabs, bare
// [aria-selected]) is NOT exempt — a stripe on every tab in the
// group is decoration; only the selected item's indicator stays.
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
if (/\[aria-(?:current|selected)/i.test(selector)) continue;
if (/\[role=["']?tab/i.test(selector)) continue;
if (/(?:^|[\s._[-])(?:active|current|selected|tabs?)(?![\w])/i.test(selector)) continue;
if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
// Structural tags where a single-edge inset shadow is depth/quoting,
// not an accent stripe.
if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
@@ -945,7 +952,7 @@ function scanCssTextForMarquee(content) {
const decls = parseCssDeclBlock(m[2]);
for (const name of infiniteAnimationNames(decls)) {
if (!marqueeKeyframes.has(name)) continue;
const key = `${selector}${name}`;
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
@@ -1480,21 +1487,23 @@ function resolveBorderRadiusPx(el, style, widthPx, win) {
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
// Selected-state / tab-strip context for accent stripes. Explicit tab
// semantics ([role=tablist]/[role=tab]) and active/current-item markers
// (aria-selected, aria-current, active/current/selected class hints)
// exempt the stripe as a selection indicator. A bare nav/menu ancestor
// deliberately does NOT — the same stripe repeated unconditionally on
// every menu item is decoration, not state.
// Selected-state context for accent stripes. Only an actual selection
// marker exempts the stripe as the standard active-item indicator:
// aria-selected="true", aria-current (any non-false value), or an
// active/current/selected class hint. Tab-strip MEMBERSHIP alone
// ([role=tablist]/[role=tab]/.tabs ancestry, aria-selected="false")
// deliberately does not — a chromatic stripe repeated on every tab in
// the group, or on every menu item, is decoration, not state; the
// selected item's own underline stays legal.
function isTabContextElement(el) {
if (!el) return false;
try {
if (el.closest?.('[role="tablist"], [role="tab"], [aria-selected], [aria-current]')) return true;
if (el.closest?.('[aria-selected="true"], [aria-current]:not([aria-current="false"])')) return true;
} catch { /* selector engine differences — fall through to class scan */ }
let cur = el, depth = 0;
while (cur && cur.nodeType === 1 && depth < 6) {
const cls = String(cur.getAttribute?.('class') || cur.className || '');
if (/(?:^|[\s_-])(?:tabs?|active|current|selected)(?:$|[\s_-])/i.test(cls)) return true;
if (/(?:^|[\s_-])(?:active|current|selected)(?:$|[\s_-])/i.test(cls)) return true;
cur = cur.parentElement;
depth++;
}
+63 -5
View File
@@ -1081,11 +1081,16 @@ describe('side-tab — pseudo-element stripe variant', () => {
expect(scanCssTextForPseudoStripe(btn)).toHaveLength(0);
});
test('skips tab/selected-state underlines (horizontal variant)', () => {
const tab = '[role="tab"][aria-selected="true"]::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }';
const tabs = '.tabs .item::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }';
expect(scanCssTextForPseudoStripe(tab)).toHaveLength(0);
expect(scanCssTextForPseudoStripe(tabs)).toHaveLength(0);
test('skips selected-state underlines, flags all-tabs underlines (horizontal variant)', () => {
const selectedTab = '[role="tab"][aria-selected="true"]::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }';
const activeItem = '.tabs .item.active::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }';
expect(scanCssTextForPseudoStripe(selectedTab)).toHaveLength(0);
expect(scanCssTextForPseudoStripe(activeItem)).toHaveLength(0);
// The same stripe on EVERY tab in the group is decoration, not state.
const allTabs = '.tabs .item::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }';
const roleTab = '[role="tab"]::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }';
expect(scanCssTextForPseudoStripe(allTabs)).toHaveLength(1);
expect(scanCssTextForPseudoStripe(roleTab)).toHaveLength(1);
});
test('skips hover-state underline affordance (horizontal variant)', () => {
@@ -1313,6 +1318,59 @@ describe('inset box-shadow stripe', () => {
`;
expect(scanCssTextForInsetStripe(css)).toHaveLength(0);
});
test('flags all-tabs inset underlines, keeps the selected tab exempt', () => {
const allTabs = '.tab { box-shadow: inset 0 -3px 0 #f59e0b; }';
const roleTab = '[role="tab"] { box-shadow: inset 0 -3px 0 #f59e0b; }';
const selectedOnly = 'nav button[aria-selected="true"] { box-shadow: inset 0 -3px 0 #f59e0b; }';
expect(scanCssTextForInsetStripe(allTabs)).toHaveLength(1);
expect(scanCssTextForInsetStripe(roleTab)).toHaveLength(1);
expect(scanCssTextForInsetStripe(selectedOnly)).toHaveLength(0);
});
test('static tab strip: chromatic border on every tab flags, selected-only underline stays silent', async () => {
// All-tabs variant: every tab in the group carries the stripe.
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head><style>
.tabs { display: flex; }
.tab { border-bottom: 3px solid #d22a2a; padding: 8px 12px; }
h1 { font-size: 40px; }
</style></head><body>
<div class="tabs" role="tablist">
<div class="tab" role="tab" aria-selected="true">Today</div>
<div class="tab" role="tab" aria-selected="false">Tomorrow</div>
<div class="tab" role="tab" aria-selected="false">Week</div>
</div>
<h1>Departures board</h1>
<p>Enough body copy for the page-level scanners to treat this as a page.</p>
</body></html>`,
}, async ({ file }) => {
const findings = await detectHtml(file);
const stripes = findings.filter(f => f.antipattern === 'side-tab');
// The two unselected tabs flag; the selected tab stays exempt.
expect(stripes).toHaveLength(2);
});
// Reserved-space variant: transparent on all, chromatic on selected only.
await withStaticFixture({
'index.html': `<!DOCTYPE html><html><head><style>
.tabs { display: flex; }
.tab { border-bottom: 3px solid transparent; padding: 8px 12px; }
.tab[aria-selected="true"] { border-bottom-color: #d22a2a; }
h1 { font-size: 40px; }
</style></head><body>
<div class="tabs" role="tablist">
<div class="tab" role="tab" aria-selected="true">Today</div>
<div class="tab" role="tab" aria-selected="false">Tomorrow</div>
</div>
<h1>Departures board</h1>
<p>Enough body copy for the page-level scanners to treat this as a page.</p>
</body></html>`,
}, async ({ file }) => {
const findings = await detectHtml(file);
expect(findings.filter(f => f.antipattern === 'side-tab')).toHaveLength(0);
});
});
});
// ---------------------------------------------------------------------------