#!/usr/bin/env python3 """Verify sidebar colors use only the UI theme; chart palettes affect charts only. Uses actual production HTML/CSS in headless Chromium, not a live RustProxy server or authenticated preference/session roundtrip. """ from pathlib import Path from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[2] HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') THEMES = ('dark', 'ultra_dark', 'light', 'cream') PALETTES = ('default', 'soft', 'technical', 'high_contrast', 'neon', 'monochrome', 'warm', 'cool') CORNERS = {'default': 0, 'square': 0, 'subtle': 4, 'rounded': 8, 'extra': 12} STYLE_INFO = r'''() => { const color = value => { const c = document.createElement('canvas'); c.width = c.height = 1; const ctx = c.getContext('2d'); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3); }; const root = getComputedStyle(document.documentElement); const active = document.querySelector('.sidebar .main-navigation .nav.active'); const idle = document.querySelector('.sidebar .main-navigation .nav:not(.active)'); const sa = getComputedStyle(active), si = getComputedStyle(idle); return { chartAccent:color(root.getPropertyValue('--chart-accent').trim()), themeSelected:color(root.getPropertyValue('--nav-active-bg').trim()), themeHovered:color(root.getPropertyValue('--nav-hover-bg').trim()), foreground:color(sa.color), background:color(sa.backgroundColor), borderLeft:color(sa.borderLeftColor), borderRight:color(sa.borderRightColor), radius:parseFloat(sa.borderTopLeftRadius), idleBackground:color(si.backgroundColor), mobileBackground:color(getComputedStyle(document.querySelector('.mobile-menu-toggle')).backgroundColor), mobileColor:color(getComputedStyle(document.querySelector('.mobile-menu-toggle')).color), navWidth:document.querySelector('.main-navigation').scrollWidth, navClientWidth:document.querySelector('.main-navigation').clientWidth, activeWidth:active.scrollWidth, activeClientWidth:active.clientWidth, }; }''' def contrast(fg, bg): def luminance(rgb): channels = [x / 255 for x in rgb] channels = [x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4 for x in channels] return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2] lo, hi = sorted((luminance(fg), luminance(bg))) return (hi + .05) / (lo + .05) with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox']) for viewport in (375, 1200): page = browser.new_page(viewport={'width': viewport, 'height': 820}) errors = [] page.on('pageerror', lambda error: errors.append(str(error))) page.set_content(HTML.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', '').replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')) page.add_style_tag(content=CSS) page.evaluate('''() => { document.documentElement.classList.remove('app-loading'); document.getElementById('app-startup').remove(); document.querySelector('.sidebar').classList.add('mobile-nav-open'); document.querySelector('.mobile-menu-toggle')?.setAttribute('aria-expanded', 'true'); }''') theme_colors = [] for theme in THEMES: baseline = None chart_accents = set() for palette in PALETTES: page.evaluate('''([theme, palette]) => { document.documentElement.dataset.theme = theme; document.documentElement.dataset.chartStyle = palette; }''', [theme, palette]) for corner, expected_radius in CORNERS.items(): page.evaluate('corner => document.documentElement.dataset.cornerStyle = corner', corner) style = page.evaluate(STYLE_INFO) assert style['radius'] == expected_radius, (viewport, theme, palette, corner, style['radius']) assert style['borderLeft'] == style['foreground'], (viewport, theme, palette, corner, style) assert style['background'] == style['themeSelected'], (viewport, theme, palette, corner, style) if viewport == 375: assert style['mobileBackground'] == style['background'], (viewport, theme, palette, corner, style) assert style['mobileColor'] == style['foreground'], (viewport, theme, palette, corner, style) assert contrast(style['foreground'], style['background']) >= 4.5, (viewport, theme, palette, corner, style) assert style['navWidth'] <= style['navClientWidth'] + 2, (viewport, theme, palette, corner, style) assert style['activeWidth'] <= style['activeClientWidth'] + 2, (viewport, theme, palette, corner, style) chart_accents.add(tuple(style['chartAccent'])) # The entire selected state is unaffected by chart-style changes. nav_colors = (tuple(style['foreground']), tuple(style['background']), tuple(style['borderLeft'])) if baseline is None: baseline = nav_colors else: assert baseline == nav_colors, (viewport, theme, palette, baseline, nav_colors) page.locator('.nav[data-panel="vhosts"]').hover() hovered = page.evaluate(STYLE_INFO) assert hovered['idleBackground'] == hovered['themeHovered'], (viewport, theme, palette, hovered) assert hovered['idleBackground'] != style['idleBackground'], (viewport, theme, palette) page.mouse.move(0, 0) if viewport == 1200 and theme == 'ultra_dark' and palette == 'cool': page.screenshot(path=str(ROOT.parent / 'sidebar_preview_desktop.png')) if viewport == 375 and theme == 'dark' and palette == 'soft': page.screenshot(path=str(ROOT.parent / 'sidebar_preview_mobile.png')) assert len(chart_accents) > 1, (viewport, theme, chart_accents) theme_colors.append(baseline) assert len(set(theme_colors)) == len(THEMES), (viewport, theme_colors) page.evaluate("document.documentElement.dir = 'rtl'") rtl = page.evaluate(STYLE_INFO) assert rtl['borderRight'] == rtl['foreground'], (viewport, rtl) assert not errors, errors print('PASS themed navigation independence: viewport', viewport, 'themes', len(THEMES), 'palettes', len(PALETTES), 'corners', len(CORNERS)) page.close() browser.close() print('PASS: sidebar changes with UI theme and corner setting, never with chart style')