Derive static-cascade color extraction from the shared named-color table

The static-html engine never emitted side-tab for `border-left: 4px solid
purple` (or any named color outside a hardcoded 9-name list) in .html
files: extractStaticColor's regex dropped the color token from border
shorthands, the side defaulted to neutral black, and checkBorders skipped
it. The same declaration in a .css file was flagged by the regex engine,
so the two engines disagreed while both exited cleanly (issue #359).

Build the extraction alternation from the same CSS_NAMED_COLORS table
parseAnyColor resolves against (longest-first, whole-token), so the set of
names the extractor recognizes and the set the parser can resolve cannot
drift apart again. STATIC_NAMED_COLORS shrinks to the one keyword
parseAnyColor deliberately refuses (`transparent` as zero-alpha), since
parseAnyColor already covers every real named color in the table.

New two-column fixture (named-color-borders.html) covers the issue
reproducers: purple shorthand + radius, rebeccapurple (substring-safe
matching), crimson top stripe, bare 3px teal, var() resolving to a named
color, and an inline style attribute — with neutral named colors
(dimgray, gainsboro, black), thin, and uniform borders as pass cases.

Prepared with AI assistance (Claude Code), directed by @pbakaus.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-25 17:45:55 -07:00
co-authored by Claude Code
parent af78b1e512
commit 7622cc8440
4 changed files with 189 additions and 10 deletions
+21 -10
View File
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
import { CSS_NAMED_COLORS, collectCssCustomProps, cssLengthToPx, parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
@@ -344,18 +344,29 @@ const STATIC_PROP_MAP = {
'overflow-y': 'overflowY',
};
// parseStaticColor tries parseAnyColor first, which already resolves every
// name in the shared CSS_NAMED_COLORS table. This fallback only carries the
// keywords parseAnyColor deliberately returns null for: the cascade needs
// `transparent` to read as an actual zero-alpha color.
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
// Named-color alternation for plucking a color token out of shorthand values
// (issue #359: a hardcoded 9-name list here silently dropped `purple`,
// `crimson`, `teal`, ... from border shorthands, so the side defaulted to
// neutral black and side-tab never fired on .html files). Derived from the
// same table parseAnyColor resolves against, so extraction and parsing can't
// drift apart. Longest-first so names containing other names as substrings
// (rebeccapurple) are matched whole.
const NAMED_COLOR_TOKENS = [...Object.keys(CSS_NAMED_COLORS), ...Object.keys(STATIC_NAMED_COLORS)]
.sort((a, b) => b.length - a.length)
.join('|');
const STATIC_COLOR_TOKEN_RE = new RegExp(
`(?:rgba?\\([^)]+\\)|oklch\\([^)]+\\)|oklab\\([^)]+\\)|lch\\([^)]+\\)|lab\\([^)]+\\)|hsla?\\([^)]+\\)|hwb\\([^)]+\\)|#[0-9a-f]{3,8}\\b|\\b(?:${NAMED_COLOR_TOKENS})\\b)`,
'i'
);
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
@@ -441,7 +452,7 @@ function extractStaticColor(value) {
}
return '';
}
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
const colorLike = raw.match(STATIC_COLOR_TOKEN_RE);
if (!colorLike) return '';
return colorLike[0];
}
+1
View File
@@ -5369,6 +5369,7 @@ function checkFirstViewportColumnOverflowDOM() {
}
export {
CSS_NAMED_COLORS,
checkBorders,
isEmojiOnlyText,
checkColors,
@@ -366,6 +366,36 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
);
});
it('named-color-borders: named-color side-tabs are flagged, neutral names pass', async () => {
// Regression for issue #359: the static cascade's shorthand color
// extraction recognized only 9 named colors, so `border-left: 4px solid
// purple` (or any of the other named colors parseAnyColor understands)
// lost its color during expansion, defaulted to neutral black, and never
// fired side-tab — while the same declaration in a .css file was flagged
// by the regex engine. The extraction list is now derived from the same
// CSS_NAMED_COLORS table the parser uses, so the two can't drift apart.
const f = await detectHtml(path.join(FIXTURES, 'named-color-borders.html'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
// Six FLAG cases: purple + radius (the issue reproducer), rebeccapurple
// (contains "purple" as a substring — whole-token matching), crimson
// top stripe, bare 3px teal, a var() resolving to a named color, and an
// inline style attribute. Each must produce exactly one side-tab.
assert.equal(
sideTabs.length, 6,
`expected 6 side-tab findings from the FLAG column, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
);
const topFindings = sideTabs.filter(r => /border-top/.test(r.snippet || ''));
assert.equal(topFindings.length, 1, `expected 1 border-top finding, got ${topFindings.length}`);
// PASS column (neutral named colors, thin, and uniform borders) must
// contribute nothing — dimgray/gainsboro/black have to parse AND read
// as neutral rather than being dropped as unknown colors.
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('modern-color-borders: regex fallback skips neutral 1px oklch dividers', () => {
const css = `
.flag-side-tab {
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Side-Tab with CSS Named Colors (purple/rebeccapurple/crimson/teal)</title>
<style>
/* Two-column fixture: left col = should-flag, right col = should-pass.
Regression for issue #359: `border-left: 4px solid purple` in a .html
file never fired side-tab because the static cascade's shorthand color
extraction only recognized 9 named colors — every other spec name
(purple, rebeccapurple, crimson, teal, ...) was dropped from the
shorthand, leaving the side at the default black, which reads as
neutral and silently skips the check. The same declaration in a .css
file was flagged by the regex engine, so the two engines disagreed. */
:root {
--accent: orange; /* named color behind a var() — must resolve and flag */
}
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 CSS named colors ── */
/* 1: the exact reproducer from issue #359 — named purple in a <style>
rule with background + padding + radius */
#flag-named-purple {
width: 400px;
background: #f6f6f6;
padding: 16px;
border-left: 4px solid purple;
border-radius: 8px;
}
/* 2: rebeccapurple — a longer name that contains another color name
("purple") as a substring; must match whole-token */
#flag-named-rebecca {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 4px solid rebeccapurple;
}
/* 3: horizontal stripe variant — named crimson riding the top edge */
#flag-named-crimson-top {
width: 400px;
background: #ffffff;
border-top: 4px solid crimson;
}
/* 4: named teal, 3px, no radius — the bare w >= 3 arm */
#flag-named-teal {
width: 400px;
background: #ffffff;
border-left: 3px solid teal;
}
/* 5: named color behind a var() in the shorthand */
#flag-named-var {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 4px solid var(--accent);
}
/* ── PASS cases: neutral named colors and non-side-tab shapes ── */
/* 1: dimgray — chromatic-looking name, neutral value; must NOT fire */
#pass-named-dimgray {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 4px solid dimgray;
}
/* 2: gainsboro — light neutral named color */
#pass-named-gainsboro {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 3px solid gainsboro;
}
/* 3: named black side border — neutral, not a colored stripe */
#pass-named-black {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 4px solid black;
}
/* 4: 1px named purple — too thin to qualify */
#pass-named-thin {
width: 400px;
background: #ffffff;
border-radius: 4px;
border-left: 1px solid purple;
}
/* 5: uniform named purple border on all four sides — not a side-tab */
#pass-named-allsides {
width: 400px;
background: #ffffff;
border: 3px solid purple;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="grid">
<div class="col" data-col="flag">
<h2>Should flag</h2>
<div class="case" id="flag-named-purple"><h3>named purple</h3><p>border-left 4px solid purple + radius</p></div>
<div class="case" id="flag-named-rebecca"><h3>rebeccapurple</h3><p>border-left 4px + radius</p></div>
<div class="case" id="flag-named-crimson-top"><h3>crimson top stripe</h3><p>border-top 4px, horizontal variant</p></div>
<div class="case" id="flag-named-teal"><h3>named teal</h3><p>border-left 3px, no radius</p></div>
<div class="case" id="flag-named-var"><h3>var() to named</h3><p>border-left 4px solid var(--accent)</p></div>
<div class="case" style="width: 400px; background: #ffffff; border-left: 4px solid purple">
<h3>inline named purple</h3><p>style attribute, issue #359 case (a)</p>
</div>
</div>
<div class="col" data-col="pass">
<h2>Should pass</h2>
<div class="case" id="pass-named-dimgray"><h3>dimgray</h3><p>neutral named color</p></div>
<div class="case" id="pass-named-gainsboro"><h3>gainsboro</h3><p>light neutral named color</p></div>
<div class="case" id="pass-named-black"><h3>named black</h3><p>neutral side border</p></div>
<div class="case" id="pass-named-thin"><h3>1px purple</h3><p>too thin to qualify</p></div>
<div class="case" id="pass-named-allsides"><h3>uniform purple</h3><p>all four sides, not a stripe</p></div>
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>