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,
});
}