#!/usr/bin/env python3 """Blocking audit for dark glass-panel UIs. Requires Playwright: python -m pip install playwright && playwright install chromium. """ from __future__ import annotations import argparse import json import os import sys from pathlib import Path from typing import Any from playwright.sync_api import Page, sync_playwright AUDIT_JS = r""" (() => { function parseColor(value) { const match = /rgba?\(([^)]+)\)/.exec(value || ''); if (!match) return {r: 255, g: 255, b: 255, a: 1}; const parts = match[1].split(',').map((x) => Number(x.trim())); return {r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1}; } function composite(top, bottom) { const alpha = top.a + (1 - top.a) * bottom.a; if (alpha === 0) return {r: bottom.r, g: bottom.g, b: bottom.b, a: 0}; const channel = (t, b) => (top.a * t + (1 - top.a) * b) / alpha; return {r: channel(top.r, bottom.r), g: channel(top.g, bottom.g), b: channel(top.b, bottom.b), a: alpha}; } function splitTopLevel(text) { const parts = []; let depth = 0; let start = 0; for (let i = 0; i < text.length; i += 1) { if (text[i] === '(') depth += 1; if (text[i] === ')') depth -= 1; if (text[i] === ',' && depth === 0) { parts.push(text.slice(start, i).trim()); start = i + 1; } } const last = text.slice(start).trim(); if (last) parts.push(last); return parts; } function parseColorOrNull(value) { const match = /rgba?\(([^)]*)\)/.exec(value || ''); if (!match) return null; const parts = match[1].split(',').map((x) => Number(x.trim())); return {r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1}; } function averageColors(colors) { if (!colors.length) return null; const total = colors.reduce((acc, color) => ({ r: acc.r + color.r, g: acc.g + color.g, b: acc.b + color.b, a: acc.a + color.a, }), {r: 0, g: 0, b: 0, a: 0}); return { r: total.r / colors.length, g: total.g / colors.length, b: total.b / colors.length, a: Math.min(1, total.a / colors.length), }; } function extractGradient(text, keyword) { const start = text.search(new RegExp(`(?:^|[^a-z-])${keyword}\\(`, 'i')); if (start < 0) return null; const open = text.indexOf('(', start + keyword.length - 1); let depth = 0; for (let i = open; i < text.length; i += 1) { if (text[i] === '(') depth += 1; if (text[i] === ')') { depth -= 1; if (depth === 0) return text.slice(open + 1, i); } } return null; } function gradientAverage(backgroundImage, keyword) { const content = extractGradient(backgroundImage, keyword); if (!content) return null; const colors = splitTopLevel(content) .map((part) => parseColorOrNull(part)) .filter(Boolean); return averageColors(colors); } function backgroundImageLayers(style) { const image = style.backgroundImage; if (!image || image === 'none') return []; return ['conic-gradient', 'radial-gradient', 'linear-gradient'] .map((keyword) => gradientAverage(image, keyword)) .filter(Boolean); } function effectiveBackground(startEl) { const chain = []; let el = startEl; while (el && el.nodeType === 1) { const style = getComputedStyle(el); chain.unshift({ color: parseColor(style.backgroundColor), images: backgroundImageLayers(style), }); el = el.parentElement; } let bg = {r: 255, g: 255, b: 255, a: 1}; for (const layer of chain) { bg = composite(layer.color, bg); for (const imageLayer of layer.images) bg = composite(imageLayer, bg); } return bg; } function channelLuminance(value) { const c = Math.max(0, Math.min(255, value)) / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function luminance(color) { return 0.2126 * channelLuminance(color.r) + 0.7152 * channelLuminance(color.g) + 0.0722 * channelLuminance(color.b); } function contrastRatio(first, second) { const a = luminance(first); const b = luminance(second); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } function visible(el) { const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width >= 2 && rect.height >= 2 && style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) >= 0.1; } function hasDirectText(el) { return Array.from(el.childNodes).some((node) => node.nodeType === 3 && node.textContent.trim().length > 0); } function isMedia(el) { return Boolean(el.closest && el.closest( 'img,picture,video,canvas,svg,iframe,.leaflet-container,[class*="leaflet"],[class*="map-container"],[class*="map-wrapper"],[data-map],[class*="player"]' )); } function inFloatingLayer(el) { return Boolean(el.closest && el.closest( '.ant-select-dropdown,.ant-picker-dropdown,.ant-modal-content,.ant-drawer-content,.ant-popover-inner,.ant-tooltip,.ant-cascader-dropdown,[role="dialog"],[role="alertdialog"]' )); } const bodyBg = effectiveBackground(document.body); const bodyLuminance = luminance(bodyBg); const issues = []; const seen = new Set(); const nodes = Array.from(document.querySelectorAll('body *')).filter(visible).slice(0, 1800); for (const el of nodes) { const style = getComputedStyle(el); const canCarryText = hasDirectText(el) || el.matches( 'input,textarea,button,select,a,label,summary,option,[role="button"],[role="option"],[role="menuitem"],[role="tab"],[aria-label]' ); if (!canCarryText) continue; const text = (el.innerText || el.value || el.placeholder || el.getAttribute('aria-label') || '').trim(); if (!text) continue; const bg = effectiveBackground(el); const styledColor = parseColor(style.color); const fg = composite(styledColor, bg); const ratio = contrastRatio(fg, bg); const fontSize = Number.parseFloat(style.fontSize); const bold = Number.parseInt(style.fontWeight, 10) >= 600; const large = fontSize >= 18.5 || (bold && fontSize >= 15); const required = large ? 3 : 4.5; const rect = el.getBoundingClientRect(); function push(reason, severity) { const item = { reason, severity, text: text.slice(0, 140), tag: el.tagName.toLowerCase(), className: String(el.className || '').slice(0, 180), id: el.id || '', background: `rgb(${Math.round(bg.r)}, ${Math.round(bg.g)}, ${Math.round(bg.b)})`, color: `rgb(${Math.round(fg.r)}, ${Math.round(fg.g)}, ${Math.round(fg.b)})`, contrast: Number(ratio.toFixed(2)), requiredContrast: required, backgroundLuminance: Number(luminance(bg).toFixed(3)), bodyLuminance: Number(bodyLuminance.toFixed(3)), size: `${Math.round(rect.width)}x${Math.round(rect.height)}`, floating: inFloatingLayer(el), }; const key = JSON.stringify([item.reason, item.text, item.className, item.contrast, item.background]); if (!seen.has(key)) { seen.add(key); issues.push(item); } } if (ratio < required) push('text contrast below threshold', 'P0'); const bgLum = luminance(bg); if (!isMedia(el) && bgLum >= 0.62 && bodyLuminance <= 0.16 && rect.width >= 32 && rect.height >= 20) { push('light component embedded in dark shell', 'P0'); } } return {bodyBg, bodyLuminance, issues}; }) """ FLOAT_OPENERS = [ '.ant-select:not(.ant-select-disabled) .ant-select-selector', '.ant-picker input', ] def route_url(base_url: str, route: str, hash_routes: bool) -> str: base = base_url.rstrip('/') route = route if route.startswith('/') else f'/{route}' return f'{base}/#{route}' if hash_routes else f'{base}{route}' def audit_page(page: Page, route: str, extra_openers: list[str]) -> dict[str, Any]: issues: list[dict[str, Any]] = [] data = page.evaluate(AUDIT_JS) issues.extend(item for item in data['issues'] if not item.get('floating')) selectors = FLOAT_OPENERS + extra_openers for selector in selectors: try: locator = page.locator(selector).first if locator.count() == 0 or not locator.is_visible(): continue locator.click(timeout=1200) page.wait_for_timeout(500) opened = page.evaluate(AUDIT_JS) issues.extend(item for item in opened['issues'] if item.get('floating')) except Exception: pass finally: for _ in range(2): page.keyboard.press('Escape') page.wait_for_timeout(120) return {'route': route, 'issues': issues} def launch_chromium(playwright): launch_options = {'headless': True} channel = os.environ.get('PANEL_PLAYWRIGHT_CHANNEL') executable_path = os.environ.get('PANEL_PLAYWRIGHT_EXECUTABLE_PATH') if channel: launch_options['channel'] = channel elif executable_path: launch_options['executable_path'] = executable_path else: local_app_data = os.environ.get('LOCALAPPDATA', '') playwright_cache = Path(local_app_data) / 'ms-playwright' if local_app_data else None has_downloaded_chromium = bool( playwright_cache and list(playwright_cache.glob('chromium-*/chrome-win64/chrome.exe')) ) if not has_downloaded_chromium: program_files_x86 = os.environ.get('ProgramFiles(x86)', '') program_files = os.environ.get('ProgramFiles', '') edge_candidates = [ Path(program_files_x86) / 'Microsoft' / 'Edge' / 'Application' / 'msedge.exe', Path(program_files) / 'Microsoft' / 'Edge' / 'Application' / 'msedge.exe', ] if any(edge.exists() for edge in edge_candidates): launch_options['channel'] = 'msedge' return playwright.chromium.launch(**launch_options) def csv_list(value: str) -> list[str]: return [item.strip() for item in value.split(',') if item.strip()] def main() -> int: parser = argparse.ArgumentParser(description='Audit panel UI contrast and light-in-dark nesting.') parser.add_argument('--base-url', required=True) parser.add_argument('--routes', required=True, help='Comma-separated routes, for example /campusState,/alarms') parser.add_argument('--hash-routes', action='store_true') parser.add_argument('--login-route', default='/login') parser.add_argument('--skip-login', action='store_true') parser.add_argument('--username', default='') parser.add_argument('--password', default='') parser.add_argument('--username-selector', default='input[type="text"],input:not([type]),input[name="username"]') parser.add_argument('--password-selector', default='input[type="password"]') parser.add_argument('--submit-text', default='') parser.add_argument('--submit-selector', default='button[type="submit"]') parser.add_argument('--open-selector', action='append', default=[], help='Optional element that opens a drawer/modal; repeatable') parser.add_argument('--viewport-width', type=int, default=1440) parser.add_argument('--viewport-height', type=int, default=900) parser.add_argument('--wait-until', default='domcontentloaded', choices=['load', 'domcontentloaded', 'networkidle', 'commit']) parser.add_argument('--out', default='.Codex/reports/panel-ui-audit.json') args = parser.parse_args() routes = csv_list(args.routes) report: dict[str, Any] = {'baseUrl': args.base_url, 'pages': []} with sync_playwright() as playwright: browser = launch_chromium(playwright) page = browser.new_page(viewport={'width': args.viewport_width, 'height': args.viewport_height}) if not args.skip_login: page.goto(route_url(args.base_url, args.login_route, args.hash_routes), wait_until=args.wait_until) if args.username: page.locator(args.username_selector).first.fill(args.username) if args.password: page.locator(args.password_selector).first.fill(args.password) if args.submit_text: page.get_by_role('button', name=args.submit_text).click() else: page.locator(args.submit_selector).first.click() page.wait_for_load_state('networkidle') page.wait_for_timeout(800) for route in routes: page.goto(route_url(args.base_url, route, args.hash_routes), wait_until=args.wait_until) page.wait_for_timeout(1200) report['pages'].append(audit_page(page, route, args.open_selector)) browser.close() out = Path(args.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') total = sum(len(page_report['issues']) for page_report in report['pages']) print(f'audited {len(report["pages"])} routes; {total} violations') print(f'report={out.resolve()}') return 1 if total else 0 if __name__ == '__main__': sys.exit(main())