Detect side-tab borders on modern color formats and label cards

- Fix isNeutralColor to handle oklch, oklab, lch, lab, hsl, and hwb
  with format-specific chroma/saturation thresholds. jsdom returns
  these formats literally, so the previous rgb-only regex caused every
  modern-format border color to be silently treated as neutral and
  skipped by checkBorders.
- Flip the unknown-format fallback from neutral to colored, so
  unrecognized color strings err on the side of detection.
- Introduce a narrower BORDER_SAFE_TAGS set (SAFE_TAGS minus 'label')
  used only by the border checks. Card-shaped clickable labels with
  thick colored side borders are now detected, while colors, motion,
  and nested-card checks continue to skip labels to avoid false
  positives on real form labels.
- Add tests/fixtures/antipatterns/modern-color-borders.html with 8
  flag cases (oklch x3, oklab, lch, lab, plus 2 label cards) and
  10 pass cases (neutrals across formats, plain inline labels,
  thin/neutral-bordered labels, colored-on-all-sides).

Reproducer (preop-portal demo): side-tab findings rise from 0 to 12.
This commit is contained in:
Paul Bakaus
2026-04-07 19:35:33 -07:00
parent d85efab51f
commit 30d1e06a75
4 changed files with 394 additions and 10 deletions
+61 -5
View File
@@ -48,6 +48,18 @@ const SAFE_TAGS = new Set([
'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
]);
// Per-check safe-tags override for the border (side-tab / border-accent)
// rule. We intentionally re-allow <label> here because card-shaped clickable
// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
// canonical side-tab anti-pattern shapes and must be detected. The rule's
// other preconditions (non-neutral color, width >= 2px on a single side,
// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
// already filter out plain inline form labels so this does not introduce
// false positives. See modern-color-borders.html for the test matrix.
const BORDER_SAFE_TAGS = new Set(
[...SAFE_TAGS].filter(t => t !== 'label')
);
const OVERUSED_FONTS = new Set([
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
]);
@@ -300,9 +312,53 @@ const ANTIPATTERNS = [
function isNeutralColor(color) {
if (!color || color === 'transparent') return true;
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
// rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0255 range.
const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgb) {
return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
}
// oklch()/lch() — chroma is the second numeric component.
// oklch chroma is ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 3 reads as tinted. jsdom emits both formats
// literally (it does NOT convert them to rgb).
const oklch = color.match(/oklch\(\s*[\d.%-]+\s+([\d.-]+)/i);
if (oklch) return parseFloat(oklch[1]) < 0.02;
const lch = color.match(/lch\(\s*[\d.%-]+\s+([\d.-]+)/i);
if (lch) return parseFloat(lch[1]) < 3;
// oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
// oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
const oklab = color.match(/oklab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
if (oklab) {
const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
return Math.hypot(a, b) < 0.02;
}
const lab = color.match(/lab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
if (lab) {
const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
return Math.hypot(a, b) < 3;
}
// hsl/hsla — saturation is the second numeric component (percent).
// Modern jsdom usually converts hsl() to rgb, but handle it directly for
// safety across versions and for any engine that preserves the format.
const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
if (hsl) return parseFloat(hsl[1]) < 10;
// hwb(hue whiteness% blackness%) — a pixel is fully gray when
// whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
if (hwb) {
const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
return (1 - Math.min(100, w + b) / 100) < 0.1;
}
// Unknown / unrecognized format — err on the side of DETECTING rather
// than silently skipping. This is the opposite of the previous default,
// which was the root cause of the oklch bug.
return false;
}
function parseRgb(color) {
@@ -369,7 +425,7 @@ function colorToHex(c) {
// ─── Section 3: Pure Detection ──────────────────────────────────────────────
function checkBorders(tag, widths, colors, radius) {
if (SAFE_TAGS.has(tag)) return [];
if (BORDER_SAFE_TAGS.has(tag)) return [];
const findings = [];
const sides = ['Top', 'Right', 'Bottom', 'Left'];
@@ -833,7 +889,7 @@ function resolveGradientStops(el, win) {
function checkElementBordersDOM(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
if (BORDER_SAFE_TAGS.has(tag)) return [];
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return [];
const style = getComputedStyle(el);
+61 -5
View File
@@ -43,6 +43,18 @@ const SAFE_TAGS = new Set([
'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
]);
// Per-check safe-tags override for the border (side-tab / border-accent)
// rule. We intentionally re-allow <label> here because card-shaped clickable
// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
// canonical side-tab anti-pattern shapes and must be detected. The rule's
// other preconditions (non-neutral color, width >= 2px on a single side,
// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
// already filter out plain inline form labels so this does not introduce
// false positives. See modern-color-borders.html for the test matrix.
const BORDER_SAFE_TAGS = new Set(
[...SAFE_TAGS].filter(t => t !== 'label')
);
const OVERUSED_FONTS = new Set([
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
]);
@@ -295,9 +307,53 @@ const ANTIPATTERNS = [
function isNeutralColor(color) {
if (!color || color === 'transparent') return true;
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return true;
return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
// rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0255 range.
const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgb) {
return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
}
// oklch()/lch() — chroma is the second numeric component.
// oklch chroma is ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 3 reads as tinted. jsdom emits both formats
// literally (it does NOT convert them to rgb).
const oklch = color.match(/oklch\(\s*[\d.%-]+\s+([\d.-]+)/i);
if (oklch) return parseFloat(oklch[1]) < 0.02;
const lch = color.match(/lch\(\s*[\d.%-]+\s+([\d.-]+)/i);
if (lch) return parseFloat(lch[1]) < 3;
// oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
// oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
const oklab = color.match(/oklab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
if (oklab) {
const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
return Math.hypot(a, b) < 0.02;
}
const lab = color.match(/lab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
if (lab) {
const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
return Math.hypot(a, b) < 3;
}
// hsl/hsla — saturation is the second numeric component (percent).
// Modern jsdom usually converts hsl() to rgb, but handle it directly for
// safety across versions and for any engine that preserves the format.
const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
if (hsl) return parseFloat(hsl[1]) < 10;
// hwb(hue whiteness% blackness%) — a pixel is fully gray when
// whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
if (hwb) {
const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
return (1 - Math.min(100, w + b) / 100) < 0.1;
}
// Unknown / unrecognized format — err on the side of DETECTING rather
// than silently skipping. This is the opposite of the previous default,
// which was the root cause of the oklch bug.
return false;
}
function parseRgb(color) {
@@ -364,7 +420,7 @@ function colorToHex(c) {
// ─── Section 3: Pure Detection ──────────────────────────────────────────────
function checkBorders(tag, widths, colors, radius) {
if (SAFE_TAGS.has(tag)) return [];
if (BORDER_SAFE_TAGS.has(tag)) return [];
const findings = [];
const sides = ['Top', 'Right', 'Bottom', 'Left'];
@@ -828,7 +884,7 @@ function resolveGradientStops(el, win) {
function checkElementBordersDOM(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
if (BORDER_SAFE_TAGS.has(tag)) return [];
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return [];
const style = getComputedStyle(el);
@@ -82,6 +82,43 @@ describe('detectHtml — jsdom fixtures', () => {
assert.ok(borderFindings.length <= 1);
});
it('modern-color-borders: oklch/oklab/lch/lab side-tabs are flagged, neutrals pass', async () => {
// Regression for the isNeutralColor bug where any non-rgb() color format
// (oklch, oklab, lch, lab — which jsdom does NOT normalize to rgb) was
// misclassified as neutral, causing checkBorders() to silently skip
// every element with a modern-color side border.
//
// Also regression for the SAFE_TAGS/label bug: card-shaped <label>
// elements (clickable checklist rows with padding + radius + colored
// side border) used to be silently skipped because checkBorders'
// SAFE_TAGS gate excluded <label>. The fix narrows that gate so card-
// shaped labels are checked while plain inline form labels still pass.
const f = await detectHtml(path.join(FIXTURES, 'modern-color-borders.html'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
// Eight FLAG cases: oklch x3, oklab, lch, lab — all colored border-left
// with a non-zero border-radius — plus two card-shaped <label> cases
// (one oklch, one rgb). Each must produce exactly one side-tab.
assert.equal(
sideTabs.length, 8,
`expected 8 side-tab findings from the FLAG column, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
);
// Every finding must be a border-left (never right/top/bottom) since
// that's the only side the fixture decorates.
for (const r of sideTabs) {
assert.match(r.snippet || '', /border-left:/, `expected border-left, got ${r.snippet}`);
}
// PASS column must contribute zero border findings of either flavor.
// There are 10 pass cases: 6 structural neutrals plus 4 labels (plain
// inline form label, label with a neutral gray border, label in a form
// row, and a label with a thin 1px colored left border). If any leaks
// through, the label exception is over-broad.
const borderAccent = f.filter(r => r.antipattern === 'border-accent-on-rounded');
assert.equal(
borderAccent.length, 0,
`expected 0 border-accent-on-rounded, got ${borderAccent.length}: ${borderAccent.map(r => r.snippet).join('; ')}`
);
});
it('typography-should-flag: detects all three issues', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'overused-font'));
+235
View File
@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Side-Tab with Modern Color Formats (oklch/lab/lch/hsl/hwb)</title>
<style>
/* Two-column fixture: left col = should-flag, right col = should-pass.
The side-tab snippet doesn't embed identifying text, so the test
asserts finding counts against the structural ids (#flag-N / #pass-N)
by reading the element via data attributes in the check path. Since
that isn't available to the Node fixture runner, we instead place a
unique number of FLAG elements (each a distinct side-tab) and verify
the side-tab count. */
body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #fafafa; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1120px; margin: 0 auto; }
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
.case { padding: 12px 16px; margin-bottom: 16px; }
.case h3 { font-size: 14px; margin: 0 0 4px; }
.case p { font-size: 13px; margin: 0; color: #64748b; }
/* ── FLAG cases: colored side-tab borders using modern color formats ── */
/* 1: oklch border-left + border-radius (the canonical real-page bug) */
#flag-oklch-1 {
width: 400px;
background: oklch(100% 0 0);
border-radius: 4px;
border-left: 3px solid oklch(65% 0.12 250);
}
/* 2: oklch mixed — neutral 1px border all around + colored 4px left +
border-radius (mirrors .medication-card from the reproducer) */
#flag-oklch-2 {
width: 400px;
background: oklch(100% 0 0);
border: 1px solid oklch(80% 0.05 250);
border-left: 4px solid oklch(75% 0.15 45);
border-radius: 4px;
}
/* 3: oklch alert pattern — colored 4px border-left + border-radius */
#flag-oklch-3 {
width: 400px;
background: oklch(98% 0.01 60);
border-left: 4px solid oklch(75% 0.18 45);
border-radius: 4px;
}
/* 4: oklab border-left + border-radius */
#flag-oklab-1 {
width: 400px;
background: #ffffff;
border-radius: 6px;
border-left: 4px solid oklab(60% 0.1 0.15);
}
/* 5: lch border-left + border-radius */
#flag-lch-1 {
width: 400px;
background: #ffffff;
border-radius: 6px;
border-left: 4px solid lch(55% 60 250);
}
/* 6: lab border-left + border-radius */
#flag-lab-1 {
width: 400px;
background: #ffffff;
border-radius: 6px;
border-left: 4px solid lab(50% 40 -30);
}
/* 7: card-shaped <label> with colored oklch side border + radius
(the checklist-item pattern from preop-portal reproducer) */
label#flag-label-oklch {
display: grid;
grid-template-columns: auto 1fr;
width: 400px;
padding: 16px;
background: oklch(100% 0 0);
border-radius: 4px;
border-left: 3px solid oklch(65% 0.12 250);
cursor: pointer;
}
/* 8: card-shaped <label> with colored rgb side border + radius —
proves the fix is not oklch-specific */
label#flag-label-rgb {
display: block;
width: 400px;
padding: 16px;
background: #ffffff;
border-radius: 6px;
border-left: 4px solid #3b82f6;
cursor: pointer;
}
/* ── PASS cases: legitimate or neutral borders, must NOT fire ── */
/* 1: oklch chroma ~0 — true neutral gray border should not fire */
#pass-oklch-neutral {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 3px solid oklch(80% 0 0);
}
/* 2: oklch low chroma (0.01) — still perceptually neutral */
#pass-oklch-nearneutral {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 3px solid oklch(75% 0.01 250);
}
/* 3: oklch colored border all around (not one-sided) — no side-tab */
#pass-oklch-allsides {
width: 400px;
background: #ffffff;
border: 3px solid oklch(65% 0.12 250);
border-radius: 4px;
}
/* 4: oklch colored 1px border-left (too thin to qualify) */
#pass-oklch-thin {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 1px solid oklch(65% 0.12 250);
}
/* 5: lab neutral (chroma near zero) — must not fire */
#pass-lab-neutral {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 3px solid lab(50% 0 0);
}
/* 6: lch neutral (chroma 0) */
#pass-lch-neutral {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 3px solid lch(50% 0 0);
}
/* 7: plain inline form label — no border, no padding, no radius.
Must NOT be flagged for anything. */
label#pass-label-plain {
font-size: 14px;
font-weight: 500;
color: #374151;
}
/* 8: form label with a small neutral gray left border — neutral color
should not fire the colored side-tab rule. */
label#pass-label-neutral-border {
display: block;
width: 400px;
padding: 8px;
background: #ffffff;
border-radius: 4px;
border-left: 3px solid #e5e7eb;
}
/* 9: inline label inside a form row — default inline layout,
no borders, must not fire. */
label#pass-label-inline {
font-size: 13px;
color: #6b7280;
margin-right: 8px;
}
/* 10: label with 1px colored left border — too thin to qualify
(matches the existing 1px thin rule for divs). */
label#pass-label-thin {
display: block;
width: 400px;
padding: 8px;
background: #ffffff;
border-radius: 4px;
border-left: 1px solid #3b82f6;
}
</style>
</head>
<body>
<div class="grid">
<div class="col" data-col="flag">
<h2>Should flag</h2>
<div class="case" id="flag-oklch-1"><h3>oklch checklist</h3><p>oklch border-left 3px + radius</p></div>
<div class="case" id="flag-oklch-2"><h3>oklch medication card</h3><p>neutral all + colored left 4px</p></div>
<div class="case" id="flag-oklch-3"><h3>oklch alert</h3><p>warm border-left 4px + radius</p></div>
<div class="case" id="flag-oklab-1"><h3>oklab card</h3><p>oklab border-left 4px + radius</p></div>
<div class="case" id="flag-lch-1"><h3>lch card</h3><p>lch border-left 4px + radius</p></div>
<div class="case" id="flag-lab-1"><h3>lab card</h3><p>lab border-left 4px + radius</p></div>
<label class="case" id="flag-label-oklch">
<input type="checkbox">
<span>Card-shaped label with oklch side border</span>
</label>
<label class="case" id="flag-label-rgb">
<input type="checkbox">
<span>Card-shaped label with rgb side border</span>
</label>
</div>
<div class="col" data-col="pass">
<h2>Should pass</h2>
<div class="case" id="pass-oklch-neutral"><h3>oklch neutral</h3><p>chroma 0 gray</p></div>
<div class="case" id="pass-oklch-nearneutral"><h3>oklch near-neutral</h3><p>chroma 0.01</p></div>
<div class="case" id="pass-oklch-allsides"><h3>oklch all sides</h3><p>uniform border</p></div>
<div class="case" id="pass-oklch-thin"><h3>oklch thin</h3><p>1px too thin</p></div>
<div class="case" id="pass-lab-neutral"><h3>lab neutral</h3><p>chroma 0</p></div>
<div class="case" id="pass-lch-neutral"><h3>lch neutral</h3><p>chroma 0</p></div>
<div>
<label for="pass-email" id="pass-label-plain">Email address</label>
<input type="email" id="pass-email">
</div>
<label id="pass-label-neutral-border">
<input type="checkbox">
<span>Label with neutral gray left border</span>
</label>
<form>
<label for="pass-name" id="pass-label-inline">Name:</label>
<input type="text" id="pass-name">
</form>
<label id="pass-label-thin">
<input type="checkbox">
<span>Label with 1px colored left border (too thin)</span>
</label>
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>