Handle emoji-only text in contrast and icon-tile detection

Emojis render as multicolor glyphs regardless of CSS \`color\`, so the
text color is irrelevant for contrast calculations. The detector was
flagging emoji icons as low-contrast whenever the surrounding bg/text
colors were close (e.g. an emoji card with text-color set to match
the bg). Adds an isEmojiOnlyText() helper that returns true when the
direct text consists entirely of emoji characters (and zero-width
joiners, variation selectors, skin-tone modifiers, regional
indicators), and skips both gray-on-color and low-contrast checks
when it's true.

Same insight fixes a missed icon-tile-stack detection: many AI-
generated cards use \`<div class="card-icon"></div>\` where the
tile contains the emoji directly as text, not an <svg>/<i> child.
The detector now also recognizes these "inline emoji icon" tiles.

Both fixes are TDD'd: new test cases in color.html (two emoji cards
with matching text/bg colors) and icon-tile-stack.html (the inline
emoji tile pattern). The test suite went from green → red → green
across both rules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-07 01:38:11 -07:00
co-authored by Claude Opus 4.6
parent 89f4ec41d7
commit 06ef4f3c14
5 changed files with 106 additions and 14 deletions
+30 -7
View File
@@ -395,8 +395,20 @@ function checkBorders(tag, widths, colors, radius) {
return findings;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
// color are meaningless for these nodes.
const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
function isEmojiOnlyText(text) {
if (!text) return false;
if (!EMOJI_CHAR_RE.test(text)) return false;
return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
}
function checkColors(opts) {
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
if (SAFE_TAGS.has(tag)) return [];
const findings = [];
@@ -405,7 +417,7 @@ function checkColors(opts) {
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
}
if (hasDirectText && textColor) {
if (hasDirectText && textColor && !isEmojiOnly) {
// Run background-dependent checks against either a solid bg or, if the
// ancestor is a gradient, against every gradient stop (use the worst case).
const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
@@ -840,7 +852,8 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el);
return checkColors({
tag,
@@ -851,6 +864,7 @@ function checkElementColorsDOM(el) {
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
isEmojiOnly: isEmojiOnlyText(directText),
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
bgImage: style.backgroundImage || '',
classList: el.getAttribute('class') || '',
@@ -867,8 +881,13 @@ function checkElementIconTileDOM(el) {
const headRect = el.getBoundingClientRect();
const sibStyle = getComputedStyle(sibling);
// The tile may either contain an <svg>/<i> icon child, OR the tile itself
// may contain an emoji/symbol character directly as its only text content
// (the "card-icon" pattern from many AI-generated demos).
const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
const iconRect = iconChild?.getBoundingClientRect();
const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
return checkIconTile({
headingTag: tag,
@@ -882,7 +901,7 @@ function checkElementIconTileDOM(el) {
siblingBgImage: sibStyle.backgroundImage || '',
siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
hasIconChild: !!iconChild,
hasIconChild: !!iconChild || hasInlineEmojiIcon,
iconChildWidth: iconRect?.width || 0,
});
}
@@ -1205,8 +1224,8 @@ function checkElementBorders(tag, style) {
}
function checkElementColors(el, style, tag, window) {
const hasText = el.textContent?.trim().length > 0;
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window);
return checkColors({
@@ -1218,6 +1237,7 @@ function checkElementColors(el, style, tag, window) {
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
isEmojiOnly: isEmojiOnlyText(directText),
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
bgImage: style.backgroundImage || '',
classList: el.getAttribute?.('class') || el.className || '',
@@ -1240,6 +1260,9 @@ function checkElementIconTile(el, tag, window) {
const iconStyle = window.getComputedStyle(iconChild);
iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
}
// Or: tile contains an emoji/symbol character directly as its only content
const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
return checkIconTile({
headingTag: tag,
@@ -1253,7 +1276,7 @@ function checkElementIconTile(el, tag, window) {
siblingBgImage: sibStyle.backgroundImage || '',
siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
hasIconChild: !!iconChild,
hasIconChild: !!iconChild || hasInlineEmojiIcon,
iconChildWidth: iconWidth,
});
}
+30 -7
View File
@@ -390,8 +390,20 @@ function checkBorders(tag, widths, colors, radius) {
return findings;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
// color are meaningless for these nodes.
const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
function isEmojiOnlyText(text) {
if (!text) return false;
if (!EMOJI_CHAR_RE.test(text)) return false;
return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
}
function checkColors(opts) {
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
if (SAFE_TAGS.has(tag)) return [];
const findings = [];
@@ -400,7 +412,7 @@ function checkColors(opts) {
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
}
if (hasDirectText && textColor) {
if (hasDirectText && textColor && !isEmojiOnly) {
// Run background-dependent checks against either a solid bg or, if the
// ancestor is a gradient, against every gradient stop (use the worst case).
const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
@@ -835,7 +847,8 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el);
return checkColors({
tag,
@@ -846,6 +859,7 @@ function checkElementColorsDOM(el) {
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
isEmojiOnly: isEmojiOnlyText(directText),
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
bgImage: style.backgroundImage || '',
classList: el.getAttribute('class') || '',
@@ -862,8 +876,13 @@ function checkElementIconTileDOM(el) {
const headRect = el.getBoundingClientRect();
const sibStyle = getComputedStyle(sibling);
// The tile may either contain an <svg>/<i> icon child, OR the tile itself
// may contain an emoji/symbol character directly as its only text content
// (the "card-icon" pattern from many AI-generated demos).
const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
const iconRect = iconChild?.getBoundingClientRect();
const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
return checkIconTile({
headingTag: tag,
@@ -877,7 +896,7 @@ function checkElementIconTileDOM(el) {
siblingBgImage: sibStyle.backgroundImage || '',
siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
hasIconChild: !!iconChild,
hasIconChild: !!iconChild || hasInlineEmojiIcon,
iconChildWidth: iconRect?.width || 0,
});
}
@@ -1200,8 +1219,8 @@ function checkElementBorders(tag, style) {
}
function checkElementColors(el, style, tag, window) {
const hasText = el.textContent?.trim().length > 0;
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window);
return checkColors({
@@ -1213,6 +1232,7 @@ function checkElementColors(el, style, tag, window) {
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
isEmojiOnly: isEmojiOnlyText(directText),
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
bgImage: style.backgroundImage || '',
classList: el.getAttribute?.('class') || el.className || '',
@@ -1235,6 +1255,9 @@ function checkElementIconTile(el, tag, window) {
const iconStyle = window.getComputedStyle(iconChild);
iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
}
// Or: tile contains an emoji/symbol character directly as its only content
const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
return checkIconTile({
headingTag: tag,
@@ -1248,7 +1271,7 @@ function checkElementIconTile(el, tag, window) {
siblingBgImage: sibStyle.backgroundImage || '',
siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
hasIconChild: !!iconChild,
hasIconChild: !!iconChild || hasInlineEmojiIcon,
iconChildWidth: iconWidth,
});
}
@@ -59,6 +59,23 @@ describe('detectHtml — jsdom fixtures', () => {
);
});
it('color: emoji-only text is never flagged as low-contrast', async () => {
// Emojis render as multicolor glyphs regardless of CSS `color`, so the
// CSS text color is irrelevant for contrast. The fixture's emoji cards
// intentionally set text color to match the bg (which would trip the
// rule for any other text). The detector must skip emoji-only nodes.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
const emojiCardColorPairs = ['#ffe4e6 on #ffe4e6', '#1a1a1a on #1a1a1a'];
const matches = f.filter(r =>
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
emojiCardColorPairs.some(pair => (r.snippet || '').includes(pair))
);
assert.equal(
matches.length, 0,
`expected no contrast findings on emoji-only text, got: ${matches.map(r => r.snippet).join('; ')}`
);
});
it('legitimate-borders: minimal false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
@@ -89,6 +106,7 @@ describe('detectHtml — icon-tile-stack', () => {
'Secure Storage',
'Easy Setup',
'Powerful Analytics',
'Emoji Inline Icon',
];
const SHOULD_PASS = [
'Sarah Chen',
+10
View File
@@ -96,6 +96,16 @@
<h3>Distinctive accents (not AI purple)</h3>
<h3 style="color: rgb(220, 38, 38); font-size: 1.25rem; margin: 0;">Red heading — not AI purple</h3>
<h3 style="color: rgb(180, 83, 9); font-size: 1.25rem; margin: 8px 0 0;">Amber heading — distinctive</h3>
<h3>Emoji on light backgrounds</h3>
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
CSS color is irrelevant for contrast. These should NOT be flagged. -->
<div class="card emoji-test" data-test="emoji-light" style="background: #ffe4e6;">
<p style="color: #ffe4e6; font-size: 24px;">⚠️ 🚨 ✨ 🎨</p>
</div>
<div class="card emoji-test" data-test="emoji-dark" style="background: #1a1a1a;">
<p style="color: #1a1a1a; font-size: 24px;">⚠️ 🚨 ✨ 🎨</p>
</div>
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
+18
View File
@@ -56,6 +56,18 @@
margin-bottom: 16px;
}
/* 5: inline emoji icon — tile contains an emoji/symbol character directly,
not an <svg> or <i> child. This is the "card-icon" pattern from many
generated demos. */
.icon-emoji {
width: 48px; height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
display: flex; align-items: center; justify-content: center;
font-size: 24px;
margin-bottom: 16px;
}
/* ── PASS cases ── */
/* a: round avatar above name (border-radius: 50% — circle, not rounded square) */
@@ -157,6 +169,12 @@
<h3>Powerful Analytics</h3>
<p>Insights that drive smarter business decisions.</p>
</div>
<div class="case">
<div class="icon-emoji">&#9889;</div>
<h3>Emoji Inline Icon</h3>
<p>The tile contains an emoji directly, no SVG child.</p>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════