Initial panel project deploy skill snapshot

This commit is contained in:
depei
2026-10-10 18:50:57 +08:00
commit 5295c1cf58
11 changed files with 2286 additions and 0 deletions
+351
View File
@@ -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())