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
@@ -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>