Security Guide

MCP server CSS monochrome media query security — consent label invisible via color-only styling on grayscale displays, border and shadow removed under @media (monochrome), consent icon color-coded with no grayscale fallback, JS matchMedia(monochrome) consent swap

CSS @media (monochrome) matches grayscale and e-ink displays — e-ink readers, grayscale medical and industrial displays, and macOS with Accessibility Grayscale enabled. Consent text or icons that rely on color hue for distinguishability become invisible when colors collapse to their luminance value in grayscale rendering. A monochrome-gated border removal makes the consent button visually indistinguishable from the page background without any opacity or visibility change.

CSS monochrome media feature — overview

@media (monochrome) reports the bits per pixel of the display's monochrome frame buffer. A value of 0 means the display is color. A positive value (1–16) means the display is monochrome, with higher values indicating more gray levels. @media (monochrome) without a value matches any non-zero (any monochrome) display. @media (min-monochrome: 1) is equivalent. Devices that match: e-ink readers (Kindle, reMarkable, Kobo, Boox), grayscale medical displays, macOS with System Preferences → Accessibility → Display → Use Grayscale. Color displays report @media (monochrome: 0), which is the default. Standard test environments are always color and never match @media (monochrome). Related: inverted-colors media query, forced-colors, prefers-contrast.

Attack 1: consent text visible only by hue — identical luminance in grayscale

Grayscale conversion uses luminance (relative brightness), not hue. Two colors that differ only in hue but share similar luminance values — for example, a medium red (#cc2222, luminance ~14%) and a dark green (#2a7a2a, luminance ~14%) — collapse to nearly identical gray values when rendered on a monochrome display. A consent label whose text color and background color share similar luminance becomes invisible: the text is present in the DOM, opacity: 1, correct dimensions — but the gray text is indistinguishable from the gray background.

/* Attack: consent text visible only by hue difference */
.consent-btn {
  background-color: #1a3a8f; /* medium-dark blue — luminance ~8% */
  color: #1f6e8c;            /* teal — luminance ~15% */
  /* On a color display: blue background, teal text — readable */
  /* On a monochrome display: both map to similar dark gray values.
     Contrast ratio in grayscale: ~1.3:1 (threshold for readability: 4.5:1).
     Text label is effectively invisible.
     opacity:1, display:block — all style checks pass. */
}

/* More precise variant using monochrome media query explicitly */
@media (monochrome) {
  .consent-btn {
    /* Omit color and background-color — inherit from page, which may also
       be poorly contrasted in grayscale. No explicit monochrome styles = no fix. */
  }
}
// Detection: relative luminance contrast check
function getLuminance(r, g, b) {
  const toLinear = c => {
    const s = c / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  };
  return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}

function parseRGB(colorStr) {
  const m = colorStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
  return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : null;
}

function auditMonochromeLuminanceContrast(consentEl) {
  const cs = getComputedStyle(consentEl);
  const fgRGB = parseRGB(cs.color);
  const bgRGB = parseRGB(cs.backgroundColor);
  if (!fgRGB || !bgRGB) return;
  const fgL = getLuminance(...fgRGB);
  const bgL = getLuminance(...bgRGB);
  const contrast = (Math.max(fgL, bgL) + 0.05) / (Math.min(fgL, bgL) + 0.05);
  if (contrast < 4.5) {
    console.warn('[SkillAudit] consent element color contrast ratio:', contrast.toFixed(2),
      '(WCAG AA requires 4.5:1) — text may be invisible on monochrome/grayscale displays;',
      'fg:', cs.color, 'bg:', cs.backgroundColor,
      'element:', consentEl);
  }
  // Additionally check for explicit monochrome rules on consent element
  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.MEDIA_RULE) continue;
        const mq = rule.conditionText || rule.media.mediaText;
        if (!/monochrome/.test(mq)) continue;
        for (const inner of rule.cssRules) {
          if (consentEl.matches(inner.selectorText)) {
            console.warn('[SkillAudit] monochrome media query rule found on consent element;',
              'review for adequate grayscale contrast;',
              'media:', mq, 'selector:', inner.selectorText);
          }
        }
      }
    } catch (e) {}
  }
}

Luminance vs hue: Grayscale conversion preserves luminance, not hue. Two very different-looking colors (red vs green) with the same luminance (~8–12%) collapse to the same gray. The W3C contrast formula uses relative luminance. A color-auditing tool that measures contrast on a color display will pass, but the same pair collapses to near-zero contrast on a monochrome display.

Attack 2: border and box-shadow removed under @media (monochrome)

A consent button that has a visible border or box-shadow on color displays can have those visual boundaries removed under @media (monochrome). Without a border, if the button's background color maps to the same gray value as the page background, the button rectangle becomes invisible — it is present in the DOM, interactive, but visually indistinguishable from the surrounding area. The attack specifically targets users who depend on high-contrast or grayscale display modes for accessibility reasons.

/* Attack: monochrome removes button boundary */
.consent-btn {
  background: #f0f0f0;      /* very light gray */
  border: 1px solid #666;   /* visible mid-gray border */
  box-shadow: 0 2px 6px rgba(0,0,0,0.15);
  color: #222;
}

@media (monochrome) {
  .consent-btn {
    border: none;          /* button boundary removed */
    box-shadow: none;      /* drop shadow removed */
    /* background:#f0f0f0 on monochrome = light gray.
       Page background typically white (#fff) or near-white.
       f0f0f0 vs ffffff: contrast 1.17:1 — indistinguishable.
       No border, no shadow, matching-luminance background.
       Button is invisible: correct DOM, correct dimensions,
       opacity:1, display:block — only the visual boundary is gone. */
  }
}
// Detection: monochrome border removal on consent elements
function auditMonochromeBorderRemoval(consentEl) {
  const isMonochrome = window.matchMedia('(monochrome)').matches;
  const cs = getComputedStyle(consentEl);
  const borderWidth = parseFloat(cs.borderTopWidth);
  const boxShadow = cs.boxShadow;
  // Check for explicit monochrome border-removal rules
  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.MEDIA_RULE) continue;
        const mq = rule.conditionText || rule.media.mediaText;
        if (!/monochrome/.test(mq)) continue;
        for (const inner of rule.cssRules) {
          if (!consentEl.matches(inner.selectorText)) continue;
          const border = inner.style.border || inner.style.borderWidth || inner.style.borderTopWidth;
          const shadow = inner.style.boxShadow;
          if (border === 'none' || border === '0' || border === '0px' || shadow === 'none') {
            console.warn('[SkillAudit] monochrome media query removes border or box-shadow from consent element;',
              'verify button remains visually distinguishable from page background in grayscale;',
              'media:', mq, 'selector:', inner.selectorText);
          }
        }
      }
    } catch (e) {}
  }
  if (isMonochrome && borderWidth === 0 && (boxShadow === 'none' || !boxShadow)) {
    const bgEl = getComputedStyle(consentEl).backgroundColor;
    const bgPage = getComputedStyle(document.body).backgroundColor;
    if (bgEl === bgPage) {
      console.warn('[SkillAudit] consent element has no border, no shadow, and background matches page background on monochrome display;',
        'button is visually invisible;', consentEl);
    }
  }
}

Attack 3: consent icon is color-coded SVG with no monochrome fallback

A consent button that uses a color-coded inline SVG icon to convey its purpose — a green checkmark for "allow," a red X for "deny," or an amber triangle for "limited" — provides no accessible information in grayscale. All three icons collapse to similar gray values, making them visually equivalent. A user who cannot distinguish the icons by color cannot identify which button grants consent and which refuses it. This is both an accessibility violation and a consent bypass for color-blind and monochrome-display users.

/* Attack: color-only consent icons — no accessible fallback */

/* Green "allow" icon */
.consent-btn-allow svg { fill: #22a645; }
/* Red "deny" icon */
.consent-btn-deny svg { fill: #dc2626; }
/* Amber "limited" icon */
.consent-btn-limited svg { fill: #d97706; }

/* All three:
   #22a645 luminance: ~38%
   #dc2626 luminance: ~14%
   #d97706 luminance: ~29%
   In grayscale: dark-ish gray, darker gray, medium gray — hard to distinguish.
   No @media (monochrome) fallback adds text labels or shape differentiation.
   No aria-label differentiates the buttons for screen reader users.
   Color-blind or grayscale users cannot tell "allow" from "deny". */

/* Missing: */
@media (monochrome) {
  /* Should add: distinct shape fills, text labels, high-contrast borders */
  /* But this block is absent — no monochrome adaptation provided */
}
// Detection: color-coded SVG icons without monochrome fallback or text labels
function auditColorCodedConsentIcons() {
  const consentEls = document.querySelectorAll('[class*="consent"], [id*="consent"]');
  for (const el of consentEls) {
    const svgs = el.querySelectorAll('svg');
    if (svgs.length === 0) continue;
    const hasTextLabel = (el.textContent?.trim().length ?? 0) > 0;
    const hasAriaLabel = el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby');
    const hasTitle = el.querySelector('title') !== null;
    if (!hasTextLabel && !hasAriaLabel && !hasTitle) {
      console.warn('[SkillAudit] consent element has SVG icon but no text label, aria-label, or SVG title;',
        'icon conveys information via shape/color only;',
        'color-blind and monochrome users cannot identify consent purpose;',
        'element:', el);
    }
    // Check if SVG fills are color-only differentiators
    for (const svg of svgs) {
      const fills = Array.from(svg.querySelectorAll('[fill]')).map(el => el.getAttribute('fill'));
      if (fills.length > 0 && !hasTextLabel && !hasAriaLabel) {
        console.warn('[SkillAudit] SVG icon uses color fills', fills, 'with no non-color fallback;',
          'check for @media (monochrome) shape-differentiation fallback;', svg);
      }
    }
  }
}

Attack 4: JS matchMedia(monochrome) swaps consent button for grayscale users

JavaScript can query window.matchMedia('(monochrome)') to detect grayscale display users and replace the interactive consent button with a non-interactive element. A change listener fires if the user toggles macOS Grayscale accessibility during a session. Unlike the CSS attacks above (which are rendering artifacts of color collapse), this is an explicit programmatic bypass that targets a specific accessibility user population.

// Attack: JS monochrome detection + consent swap
const mql = window.matchMedia('(monochrome)');

function applyMonochromeMode(isMonochrome) {
  const btn = document.querySelector('.consent-btn');
  if (!btn) return;
  if (isMonochrome) {
    // "Switching to e-ink optimized layout..."
    const fake = document.createElement('div');
    fake.className = btn.className;
    fake.textContent = btn.textContent;
    fake.setAttribute('role', 'button'); // looks interactive
    // No click handler — cannot be activated
    btn.parentNode.replaceChild(fake, btn);
  }
}

applyMonochromeMode(mql.matches);
mql.addEventListener('change', e => applyMonochromeMode(e.matches));

// Subtler: just disable the button for monochrome users
if (window.matchMedia('(monochrome)').matches) {
  document.querySelector('.consent-btn')?.setAttribute('disabled', '');
  // or:
  document.querySelector('.consent-btn')?.style.setProperty('pointer-events', 'none');
}
// Detection: JS source scan for monochrome matchMedia + consent manipulation
function auditMonochromeJS() {
  for (const script of document.querySelectorAll('script')) {
    const src = script.textContent;
    if (!src || !/monochrome/.test(src)) continue;
    const hasConsentContext = /consent|banner|modal|button|btn/i.test(src);
    if (!hasConsentContext) continue;
    const hasManipulation = [
      /replaceChild|replaceWith|createElement/,
      /setAttribute.*disabled/,
      /pointer-events.*none/,
      /style\.(display|opacity|visibility)\s*=/,
      /\.remove\(\)/,
    ].some(p => p.test(src));
    if (hasManipulation) {
      console.warn('[SkillAudit] script uses @media (monochrome) matchMedia check with consent-related DOM manipulation;',
        'verify consent button remains interactive on grayscale/e-ink displays;',
        'current monochrome state:', window.matchMedia('(monochrome)').matches,
        'script:', script.src || '(inline)');
    }
  }
}

Findings summary

High consent text distinguishable by hue only — foreground and background share similar luminance values; on monochrome/grayscale display both collapse to same gray; contrast ratio in grayscale below 4.5:1 (WCAG AA); detected by computing relative luminance contrast ratio from computed color and background-color values.
High @media (monochrome) removes border and box-shadow from consent button — without boundary, button background matches page background in grayscale; element is visually invisible; detected by CSSOM scan for monochrome rules removing border/box-shadow and cross-checking background color luminance against page background.
Medium consent icon is color-coded SVG with no accessible fallback — all color states (allow/deny/limited) collapse to similar gray in monochrome; no text label, aria-label, or SVG title differentiates purpose; detected by checking consent elements with SVG icons against absence of text content, aria attributes, and SVG title elements.
Medium JS matchMedia(monochrome) swaps consent button — interactive button replaced with non-interactive clone, or disabled, for grayscale display users; change listener re-applies on macOS Grayscale toggle; detected by source scan for monochrome matchMedia combined with replaceChild/disabled/pointer-events manipulation near consent elements.

SkillAudit audits CSS monochrome media query rules on consent elements, computes luminance contrast ratios to detect color-only distinguishability, checks for border/shadow removal under monochrome, and scans JavaScript for matchMedia(monochrome) combined with DOM manipulation. Run a free audit on your MCP server.