Initial panel project deploy skill snapshot
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
#!/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())
|
||||
|
||||
Reference in New Issue
Block a user