Detect dark mode rendering issues including contrast failures, missing theme tokens, image inversions, and transition glitches across components.
You are an expert QA automation engineer specializing in dark mode testing, theme validation, and visual accessibility verification. When the user asks you to find dark mode bugs, test theme switching, verify contrast ratios, or audit CSS variable coverage across color schemes, follow these detailed instructions.
Every color must come from a theme token -- Hardcoded color values (hex, rgb, named colors) in CSS are dark mode bugs waiting to happen. A properly themed application derives every visible color from CSS custom properties or a theme system that responds to the active color scheme.
WCAG contrast ratios are non-negotiable -- Text must maintain a minimum 4.5:1 contrast ratio against its background in both light and dark modes. Large text (18px+ or 14px+ bold) requires 3:1 minimum. Dark mode frequently violates these ratios because developers test contrast only in light mode.
Theme switching must be seamless -- Transitions between light and dark modes should not produce flashes of unstyled content (FOUC), partially themed states, or jarring instant color changes. Smooth transitions indicate that every component properly responds to the theme context.
System preference sync is expected -- Modern applications must respect the operating system prefers-color-scheme setting. Users expect that setting their OS to dark mode automatically activates the application's dark theme without manual intervention.
Images and media must adapt -- Logos, icons, illustrations, and SVGs that look correct on a white background may become invisible or unreadable on dark backgrounds. Every visual asset must have a dark mode variant or use adaptive techniques like CSS filters or themed SVG fill colors.
Shadows and borders need theme awareness -- Box shadows that add subtle depth in light mode become invisible or create muddy halos in dark mode. Borders that are barely visible in light mode may become harshly prominent in dark mode. Both require theme-specific values.
Third-party components must integrate -- Embedded widgets, iframes, third-party UI libraries, and user-generated content must all participate in the theme. A bright white embedded map in an otherwise dark interface is a dark mode failure even though the application code is correct.
Organize your dark mode testing suite with this directory structure:
tests/
dark-mode/
contrast-validation.spec.ts
css-variable-audit.spec.ts
hardcoded-colors.spec.ts
theme-transitions.spec.ts
system-preference-sync.spec.ts
image-adaptation.spec.ts
component-coverage.spec.ts
focus-indicators.spec.ts
fixtures/
theme-helpers.ts
color-utils.ts
helpers/
contrast-calculator.ts
theme-detector.ts
color-parser.ts
screenshot-comparator.ts
reports/
dark-mode-report.json
dark-mode-report.html
playwright.config.ts
Build a color utility that parses CSS color values and calculates WCAG contrast ratios programmatically.
export interface RGB {
r: number;
g: number;
b: number;
}
export interface ContrastResult {
ratio: number;
passesAA: boolean;
passesAAA: boolean;
passesAALargeText: boolean;
foreground: RGB;
background: RGB;
}
export function parseColor(color: string): RGB | null {
const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
return {
r: parseInt(rgbMatch[1]),
g: parseInt(rgbMatch[2]),
b: parseInt(rgbMatch[3]),
};
}
const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})/);
if (hexMatch) {
return {
r: parseInt(hexMatch[1], 16),
g: parseInt(hexMatch[2], 16),
b: parseInt(hexMatch[3], 16),
};
}
const shortHexMatch = color.match(/^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/);
if (shortHexMatch) {
return {
r: parseInt(shortHexMatch[1] + shortHexMatch[1], 16),
g: parseInt(shortHexMatch[2] + shortHexMatch[2], 16),
b: parseInt(shortHexMatch[3] + shortHexMatch[3], 16),
};
}
return null;
}
export function relativeLuminance(rgb: RGB): number {
const [rs, gs, bs] = [rgb.r / 255, rgb.g / 255, rgb.b / 255].map((c) =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
);
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
export function contrastRatio(fg: RGB, bg: RGB): number {
const lum1 = relativeLuminance(fg);
const lum2 = relativeLuminance(bg);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
export function checkContrast(fg: RGB, bg: RGB): ContrastResult {
const ratio = contrastRatio(fg, bg);
return {
ratio: Math.round(ratio * 100) / 100,
passesAA: ratio >= 4.5,
passesAAA: ratio >= 7,
passesAALargeText: ratio >= 3,
foreground: fg,
background: bg,
};
}
export function isLightColor(rgb: RGB): boolean {
return relativeLuminance(rgb) > 0.5;
}
export function isDarkColor(rgb: RGB): boolean {
return relativeLuminance(rgb) < 0.2;
}
import { Page } from '@playwright/test';
export interface ThemeColors {
background: string;
text: string;
primary: string;
backgroundLuminance: number;
}
export async function getActiveColorScheme(page: Page): Promise<'light' | 'dark'> {
return page.evaluate(() => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
}
export async function setThemeViaMediaQuery(
page: Page,
scheme: 'light' | 'dark'
): Promise<void> {
await page.emulateMedia({ colorScheme: scheme });
await page.waitForTimeout(300);
}
export async function toggleThemeViaUI(page: Page): Promise<boolean> {
const toggleSelectors = [
'[data-testid="theme-toggle"]',
'[aria-label*="theme" i]',
'[aria-label*="dark" i]',
'[aria-label*="light" i]',
'button:has([class*="moon"])',
'button:has([class*="sun"])',
'.theme-toggle',
'#theme-toggle',
];
for (const selector of toggleSelectors) {
const element = page.locator(selector).first();
if (await element.isVisible().catch(() => false)) {
await element.click();
await page.waitForTimeout(500);
return true;
}
}
return false;
}
export async function getCurrentThemeColors(page: Page): Promise<ThemeColors> {
return page.evaluate(() => {
const body = document.body;
const style = window.getComputedStyle(body);
const rootStyle = window.getComputedStyle(document.documentElement);
const bg = style.backgroundColor;
const bgMatch = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
let luminance = 0;
if (bgMatch) {
const [r, g, b] = [+bgMatch[1] / 255, +bgMatch[2] / 255, +bgMatch[3] / 255].map(
(c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))
);
luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
return {
background: bg,
text: style.color,
primary:
rootStyle.getPropertyValue('--color-primary').trim() ||
rootStyle.getPropertyValue('--primary').trim() ||
'unknown',
backgroundLuminance: luminance,
};
});
}
Programmatically verify that all text elements meet WCAG AA contrast requirements against their effective background in dark mode.
import { test, expect } from '@playwright/test';
import { setThemeViaMediaQuery } from '../fixtures/theme-helpers';
interface ContrastIssue {
element: string;
text: string;
foreground: string;
background: string;
ratio: number;
required: number;
fontSize: string;
}
test.describe('WCAG Contrast Validation in Dark Mode', () => {
test('all text meets WCAG AA contrast requirements', async ({ page }) => {
await setThemeViaMediaQuery(page, 'dark');
await page.goto('/', { waitUntil: 'networkidle' });
const issues: ContrastIssue[] = await page.evaluate(() => {
const results: ContrastIssue[] = [];
const textSelectors =
'h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, input, textarea';
const textElements = document.querySelectorAll(textSelectors);
function parseRGB(color: string) {
const m = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
}
function getLuminance(r: number, g: number, b: number) {
const [rs, gs, bs] = [r / 255, g / 255, b / 255].map((c) =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
);
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function getContrast(fg: { r: number; g: number; b: number }, bg: { r: number; g: number; b: number }) {
const l1 = getLuminance(fg.r, fg.g, fg.b);
const l2 = getLuminance(bg.r, bg.g, bg.b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function getEffectiveBackground(el: Element): string {
let current: Element | null = el;
while (current) {
const style = window.getComputedStyle(current);
const bg = style.backgroundColor;
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
return bg;
}
current = current.parentElement;
}
return 'rgb(0, 0, 0)';
}
textElements.forEach((el) => {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const text = el.textContent?.trim() || '';
if (text.length === 0) return;
const fgRgb = parseRGB(style.color);
const bgRgb = parseRGB(getEffectiveBackground(el));
if (!fgRgb || !bgRgb) return;
const ratio = getContrast(fgRgb, bgRgb);
const fontSize = parseFloat(style.fontSize);
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700);
const required = isLargeText ? 3.0 : 4.5;
if (ratio < required) {
results.push({
element: `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}`,
text: text.substring(0, 40),
foreground: style.color,
background: getEffectiveBackground(el),
ratio: Math.round(ratio * 100) / 100,
required,
fontSize: style.fontSize,
});
}
});
return results;
});
expect(
issues,
`WCAG AA contrast failures in dark mode:\n${issues
.map(
(i) =>
` ${i.element} "${i.text}": ${i.ratio}:1 (need ${i.required}:1) fg=${i.foreground} bg=${i.background}`
)
.join('\n')}`
).toHaveLength(0);
});
test('placeholder text meets contrast requirements in dark mode', async ({ page }) => {
await setThemeViaMediaQuery(page, 'dark');
await page.goto('/login', { waitUntil: 'networkidle' });
const placeholderIssues = await page.evaluate(() => {
const issues: string[] = [];
const inputs = document.querySelectorAll('input[placeholder], textarea[placeholder]');
inputs.forEach((input) => {
const el = input as HTMLInputElement;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
if (rect.width === 0) return;
const bgMatch = style.backgroundColor.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!bgMatch) return;
const bgLum =
(0.299 * +bgMatch[1] + 0.587 * +bgMatch[2] + 0.114 * +bgMatch[3]) / 255;
if (bgLum < 0.2) {
const borderMatch = style.borderColor.match(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/
);
if (borderMatch) {
const borderLum =
(0.299 * +borderMatch[1] + 0.587 * +borderMatch[2] + 0.114 * +borderMatch[3]) / 255;
if (Math.abs(bgLum - borderLum) < 0.05) {
issues.push(
`Input "${el.name || el.type}": border barely visible in dark mode`
);
}
}
}
});
return issues;
});
expect(
placeholderIssues,
`Input visibility issues in dark mode:\n${placeholderIssues.join('\n')}`
).toHaveLength(0);
});
});
Verify that all CSS custom properties used for theming are defined in both light and dark mode, and that color-related variables change between themes.
import { test, expect } from '@playwright/test';
import { setThemeViaMediaQuery } from '../fixtures/theme-helpers';
test.describe('CSS Variable Coverage Audit', () => {
test('all theme CSS variables are defined in dark mode', async ({ page }) => {
await setThemeViaMediaQuery(page, 'light');
await page.goto('/', { waitUntil: 'networkidle' });
const lightVars = await page.evaluate(() => {
const root = document.documentElement;
const style = window.getComputedStyle(root);
const vars: Record<string, string> = {};
for (const sheet of Array.from(document.styleSheets)) {
try {
for (const rule of Array.from(sheet.cssRules)) {
const styleRule = rule as CSSStyleRule;
if (styleRule.style) {
for (let i = 0; i < styleRule.style.length; i++) {
const prop = styleRule.style[i];
if (prop.startsWith('--')) {
vars[prop] = style.getPropertyValue(prop).trim();
}
}
}
}
} catch {
// Cross-origin stylesheets are not readable
}
}
return vars;
});
await setThemeViaMediaQuery(page, 'dark');
await page.waitForTimeout(500);
const darkVars = await page.evaluate((varNames: string[]) => {
const root = document.documentElement;
const style = window.getComputedStyle(root);
const results: Record<string, { defined: boolean; value: string }> = {};
for (const name of varNames) {
const value = style.getPropertyValue(name).trim();
results[name] = { defined: value !== '', value };
}
return results;
}, Object.keys(lightVars));
const undefinedInDark: string[] = [];
const unchangedColorVars: string[] = [];
for (const [varName, info] of Object.entries(darkVars)) {
if (!info.defined) {
undefinedInDark.push(varName);
} else if (
info.value === lightVars[varName] &&
(varName.includes('color') ||
varName.includes('bg') ||
varName.includes('background') ||
varName.includes('text') ||
varName.includes('border') ||
varName.includes('shadow'))
) {
unchangedColorVars.push(`${varName}: ${info.value}`);
}
}
expect(
undefinedInDark,
`CSS variables undefined in dark mode:\n${undefinedInDark.join('\n')}`
).toHaveLength(0);
if (unchangedColorVars.length > 0) {
console.warn(
`Color variables unchanged between themes (may be intentional):\n${unchangedColorVars.join('\n')}`
);
}
});
test('no CSS variables resolve to empty values in dark mode', async ({ page }) => {
await setThemeViaMediaQuery(page, 'dark');
await page.goto('/', { waitUntil: 'networkidle' });
const emptyVars = await page.evaluate(() => {
const empty: string[] = [];
const root = document.documentElement;
const style = window.getComputedStyle(root);
for (const sheet of Array.from(document.styleSheets)) {
try {
for (const rule of Array.from(sheet.cssRules)) {
const styleRule = rule as CSSStyleRule;
if (styleRule.style) {
for (let i = 0; i < styleRule.style.length; i++) {
const prop = styleRule.style[i];
if (prop.startsWith('--')) {
const value = style.getPropertyValue(prop).trim();
if (value === '') {
empty.push(prop);
}
}
}
}
}
} catch {
// Skip cross-origin sheets
}
}
return [...new Set(empty)];
});
expect(
emptyVars,
`Empty CSS variables in dark mode:\n${emptyVars.join('\n')}`
).toHaveLength(0);
});
});
Find elements that use hardcoded colors instead of theme tokens, which will fail to adapt in dark mode.
import { test, expect } from '@playwright/test';
import { setThemeViaMediaQuery } from '../fixtures/theme-helpers';
test.describe('Hardcoded Color Detection', () => {
test('no visible elements use hardcoded white backgrounds in dark mode', async ({ page }) => {
await setThemeViaMediaQuery(page, 'dark');
await page.goto('/', { waitUntil: 'networkidle' });
const whiteBackgrounds = await page.evaluate(() => {
const issues: string[] = [];
const elements = document.querySelectorAll('*');
elements.forEach((el) => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
if (style.display === 'none' || style.visibility === 'hidden') return;
const bg = style.backgroundColor;
const match = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (match) {
const [r, g, b] = [+match[1], +match[2], +match[3]];
if (r > 240 && g > 240 && b > 240 && rect.width > 50 && rect.height > 20) {
const tag = el.tagName.toLowerCase();
const cls =
el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\s+/)[0]
: '';
issues.push(
`${tag}${cls}: white background (${bg}) at ${Math.round(rect.width)}x${Math.round(rect.height)}px`
);
}
}
});
return issues;
});
expect(
whiteBackgrounds,
`Elements with hardcoded white backgrounds in dark mode:\n${whiteBackgrounds.join('\n')}`
).toHaveLength(0);
});
test('no text uses hardcoded dark colors that become invisible in dark mode', async ({
page,
}) => {
await setThemeViaMediaQuery(page, 'dark');
await page.goto('/', { waitUntil: 'networkidle' });
const darkTextIssues = await page.evaluate(() => {
const issues: string[] = [];
const textElements = document.querySelectorAll(
'h1, h2, h3, h4, h5, h6, p, span, a, li, label'
);
textElements.forEach((el) => {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return;
const rect = el.getBoundingClientRect();
if (rect.width === 0) return;
const text = el.textContent?.trim() || '';
if (text.length === 0) return;
const colorMatch = style.color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (colorMatch) {
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
const luminance =
(0.2126 * (r / 255) + 0.7152 * (g / 255) + 0.0722 * (b / 255));
if (luminance < 0.1) {
issues.push(
`${el.tagName.toLowerCase()} "${text.substring(0, 30)}": dark text (${style.color}) likely invisible on dark background`
);
}
}
});
return issues;
});
expect(
darkTextIssues,
`Dark text on dark backgrounds:\n${darkTextIssues.join('\n')}`
).toHaveLength(0);
});
test('inline styles do not contain hardcoded color values', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
const inlineColorIssues = await page.evaluate(() => {
const issues: string[] = [];
const elements = document.querySelectorAll('[style]');
const colorRegex =
/(color|background|border|shadow)\s*:\s*(#[0-9a-fA-F]{3,8}|rgb|hsl|white|black)/gi;
elements.forEach((el) => {
const styleAttr = el.getAttribute('style') || '';
const matches = styleAttr.match(colorRegex);
if (matches) {
const tag = el.tagName.toLowerCase();
issues.push(`${tag}: inline ${matches.join(', ')}`);
}
});
return issues;
});
if (inlineColorIssues.length > 0) {
console.warn(
`Inline hardcoded colors found (may not respect dark mode):\n${inlineColorIssues.join('\n')}`
);
}
});
});
Verify that switching between light and dark mode produces no visual artifacts, flashes, or partially-themed states.
import { test, expect } from '@playwright/test';
import {
setThemeViaMediaQuery,
toggleThemeViaUI,
getCurrentThemeColors,
} from '../fixtures/theme-helpers';
test.describe('Theme Transition Testing', () => {
test('no flash of unstyled content when loading in dark mode', async ({ page }) => {
await setThemeViaMediaQuery(page, 'dark');
let flashDetected = false;
page.on('console', (msg) => {
if (msg.text().includes('FOUC_DETECTED')) {
flashDetected = true;
}
});
await page.addInitScript(() => {
const observer = new MutationObserver(() => {
const bg = window.getComputedStyle(document.documentElement).backgroundColor;
const match = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (match) {
const luminance = (+match[1] + +match[2] + +match[3]) / 3;
if (luminance > 200) {
console.log('FOUC_DETECTED: white background during dark mode load');
}
}
});
if (document.documentElement) {
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'style'],
});
}
});
await page.goto('/', { waitUntil: 'networkidle' });
expect(flashDetected, 'Flash of light content detected during dark mode page load').toBe(
false
);
});
test('theme change via system preference updates all components', async ({ page }) => {
await setThemeViaMediaQuery(page, 'light');
await page.goto('/', { waitUntil: 'networkidle' });
const lightColors = await getCurrentThemeColors(page);
await setThemeViaMediaQuery(page, 'dark');
await page.waitForTimeout(500);
const darkColors = await getCurrentThemeColors(page);
expect(
lightColors.background,
'Background should change between light and dark modes'
).not.toBe(darkColors.background);
expect(
darkColors.backgroundLuminance,
`Dark mode background luminance ${darkColors.backgroundLuminance.toFixed(2)} is too high`
).toBeLessThan(0.3);
});
test('all major components respond to theme change', async ({ page }) => {
await setThemeViaMediaQuery(page, 'light');
await page.
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add PramodDutta/Dark Mode Bug Finder下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer
Tags:dark-mode, theming, contrast, accessibility, css-variables, color-scheme