352 lines
13 KiB
Python
352 lines
13 KiB
Python
#!/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())
|
||
|
|
|