diff --git a/src/detect-antipatterns-browser.js b/src/detect-antipatterns-browser.js index 9f0ce2f75..40e3e9f56 100644 --- a/src/detect-antipatterns-browser.js +++ b/src/detect-antipatterns-browser.js @@ -1269,12 +1269,25 @@ function checkElementQuality(el, style, tag, window) { return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null }); } -function checkElementBorders(tag, style) { +function checkElementBorders(tag, style, overrides) { const sides = ['Top', 'Right', 'Bottom', 'Left']; const widths = {}, colors = {}; for (const s of sides) { widths[s] = parseFloat(style[`border${s}Width`]) || 0; colors[s] = style[`border${s}Color`] || ''; + // jsdom silently drops any border shorthand containing var(), leaving + // both width and color empty on the computed style. When the detectHtml + // pre-pass pulled a resolved value off the rule, use it to fill in the + // missing side so the side-tab check can run. Real browsers resolve + // var() natively, so this fallback is a no-op in the browser path. + if (widths[s] === 0 && overrides && overrides[s]) { + widths[s] = overrides[s].width; + colors[s] = overrides[s].color; + } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) { + // Longhand case: jsdom kept the width but left the color as the + // literal `var(...)` string. Substitute the resolved color. + colors[s] = overrides[s].color; + } } return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0); } diff --git a/src/detect-antipatterns.mjs b/src/detect-antipatterns.mjs index 23ff154a3..d469596b8 100644 --- a/src/detect-antipatterns.mjs +++ b/src/detect-antipatterns.mjs @@ -1264,12 +1264,25 @@ function checkElementQuality(el, style, tag, window) { return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null }); } -function checkElementBorders(tag, style) { +function checkElementBorders(tag, style, overrides) { const sides = ['Top', 'Right', 'Bottom', 'Left']; const widths = {}, colors = {}; for (const s of sides) { widths[s] = parseFloat(style[`border${s}Width`]) || 0; colors[s] = style[`border${s}Color`] || ''; + // jsdom silently drops any border shorthand containing var(), leaving + // both width and color empty on the computed style. When the detectHtml + // pre-pass pulled a resolved value off the rule, use it to fill in the + // missing side so the side-tab check can run. Real browsers resolve + // var() natively, so this fallback is a no-op in the browser path. + if (widths[s] === 0 && overrides && overrides[s]) { + widths[s] = overrides[s].width; + colors[s] = overrides[s].color; + } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) { + // Longhand case: jsdom kept the width but left the color as the + // literal `var(...)` string. Substitute the resolved color. + colors[s] = overrides[s].color; + } } return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0); } @@ -2393,6 +2406,178 @@ function isFullPage(content) { return /]|]/i.test(stripped); } +// --------------------------------------------------------------------------- +// jsdom CSS-variable border override map +// --------------------------------------------------------------------------- +// +// jsdom's CSSOM silently drops any border shorthand that contains a var() +// reference — the computed style for the element then shows empty width, +// empty style, and a default black color. That's enough to hide the most +// common real-world side-tab pattern in AI-generated pages: +// +// :root { --brand: #87a8ff; } +// .card { border-left: 5px solid var(--brand); border-radius: 4px; } +// +// Real browsers (and therefore the browser detector path) resolve var() +// natively, so this only affects the Node jsdom path. +// +// This pre-pass walks the stylesheets, finds any rule whose per-side or +// all-sides border property contains var(), resolves the var() against +// :root-level custom properties (read from the documentElement's computed +// style, which jsdom DOES handle correctly), and attaches the resolved +// width+color to every element that matches the rule's selector. The +// Node-side `checkElementBorders` adapter consumes that map as a fallback +// whenever jsdom's computed style came back empty. +// +// Limitations (intentional, to keep the pass simple): +// * Only :root-level custom properties are resolved. Scoped overrides on +// descendants are not tracked — uncommon in practice and would require +// a per-element cascade walk. +// * @media / @supports wrapped rules are ignored (jsdom often mishandles +// these anyway). +// * The fallback only fills sides that jsdom left empty, so any rule +// whose border parses normally still wins via the computed style. + +const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i; + +// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb(). +// CSS variables typically hold hex or named colors, so normalize those to +// rgb() before handing the value off to the shared check. Anything we don't +// recognise is passed through unchanged — isNeutralColor then treats it as +// non-neutral, which is the safer default (matches the oklch-era bugfix). +const NAMED_COLORS = { + white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128], + grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0], + green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0], +}; + +function normalizeColorForCheck(value) { + if (!value) return value; + const v = value.trim(); + const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); + if (hex6) { + const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)]; + return `rgb(${r}, ${g}, ${b})`; + } + const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); + if (hex3) { + const [r, g, b] = [ + parseInt(hex3[1] + hex3[1], 16), + parseInt(hex3[2] + hex3[2], 16), + parseInt(hex3[3] + hex3[3], 16), + ]; + return `rgb(${r}, ${g}, ${b})`; + } + const named = NAMED_COLORS[v.toLowerCase()]; + if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`; + return v; +} + +function buildBorderOverrideMap(document, window) { + const map = new Map(); + const rootStyle = window.getComputedStyle(document.documentElement); + + function resolveVar(value, depth = 0) { + if (!value || depth > 10 || !value.includes('var(')) return value; + return value.replace( + /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g, + (_, name, fallback) => { + const v = rootStyle.getPropertyValue(name).trim(); + if (v) return resolveVar(v, depth + 1); + if (fallback) return resolveVar(fallback.trim(), depth + 1); + return ''; + } + ); + } + + function parseShorthand(text) { + const m = text.trim().match(BORDER_SHORTHAND_RE); + if (!m) return null; + return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) }; + } + + // Read from the per-property accessors on rule.style. jsdom preserves + // each border-* shorthand it parsed, even when the overall cssText has + // been truncated (e.g. a `border: 1px solid var(...)` followed by a + // `border-left: ...` loses the first declaration but keeps the second). + const SIDE_PROPS = [ + ['borderLeft', 'Left'], + ['borderRight', 'Right'], + ['borderTop', 'Top'], + ['borderBottom', 'Bottom'], + ['borderInlineStart', 'Left'], + ['borderInlineEnd', 'Right'], + ]; + + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules || []; } catch { continue; } + for (const rule of rules) { + // CSSStyleRule only; skip @media / @keyframes / @supports wrappers. + if (rule.type !== 1 || !rule.style || !rule.selectorText) continue; + + const perSide = {}; + + for (const [prop, side] of SIDE_PROPS) { + const val = rule.style[prop]; + if (!val || !val.includes('var(')) continue; + const parsed = parseShorthand(resolveVar(val)); + if (parsed && parsed.color) perSide[side] = parsed; + } + + // Uniform `border: @@ -203,6 +284,13 @@ Card-shaped label with rgb side border +

var() shorthand

border-left 5px + var(--brand)

+

var() mixed

neutral all + colored var left

+

var() right side

border-right 4px + var(--terracotta)

+

Should pass

@@ -228,6 +316,9 @@ Label with 1px colored left border (too thin) +

var() neutral

--line resolves to gray

+

var() thin

1px too thin to qualify

+

var() all sides

uniform, not a side-tab