diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58d08c2a..7e20b967 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -940,3 +940,37 @@ input[type="date"]:focus { stroke-width: 5; } } + +/* ── Audit / high-contrast theme ───────────────────────────────────────────── + A deliberately flat, no-texture, no-glow render (set via ). It is a REAL browser render — layout, positions and sizes are + unchanged — but the decorative lagoon background, grid, blur and glows are + stripped so OpenCV layout/balance scans get a clean, deterministic image. + This is also the groundwork for a real high-contrast accessibility theme. */ +html[data-theme="contrast"] body, +html[data-theme="contrast"] #root { + background: #050608 !important; +} +html[data-theme="contrast"] .lagoon-caustics, +html[data-theme="contrast"] .grid-overlay { + display: none !important; +} +html[data-theme="contrast"] .graph-container, +html[data-theme="contrast"] .graph-container svg { + background: #050608 !important; +} +/* strip decoration that muddies pixel analysis: blur, glow, soft shadows, gradients-on-text */ +html[data-theme="contrast"] *, +html[data-theme="contrast"] *::before, +html[data-theme="contrast"] *::after { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + box-shadow: none !important; + text-shadow: none !important; +} +/* node glow/drop-shadow filters off → solid, crisp shapes for detection */ +html[data-theme="contrast"] .graph-container svg .node, +html[data-theme="contrast"] .graph-container svg .node *, +html[data-theme="contrast"] .node-selected { + filter: none !important; +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index c63a5ae7..6e7fbaf2 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -6,6 +6,17 @@ import { apolloClient } from './lib/apollo'; import App from './App'; import './index.css'; +// Apply an explicit theme before first paint. `contrast` is a flat, no-texture, +// high-contrast render used by OpenCV layout audits — and the groundwork for a +// real high-contrast accessibility theme. Source priority: ?theme= URL param, +// then the persisted choice (graphdone:theme). Default theme = no attribute. +try { + const urlTheme = new URLSearchParams(window.location.search).get('theme'); + if (urlTheme) localStorage.setItem('graphdone:theme', urlTheme); + const theme = urlTheme || localStorage.getItem('graphdone:theme'); + if (theme) document.documentElement.setAttribute('data-theme', theme); +} catch { /* non-browser / storage blocked */ } + const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); diff --git a/tests/diagnostics/graph-balance.spec.ts b/tests/diagnostics/graph-balance.spec.ts new file mode 100644 index 00000000..2d549878 --- /dev/null +++ b/tests/diagnostics/graph-balance.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import { login, TEST_USERS, getBaseURL } from '../helpers/auth'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import * as path from 'node:path'; + +/** + * Graph balance / layout metrics (@balance), OpenCV-driven. + * + * Clips a screenshot to the graph canvas (`.graph-container`, which excludes the + * nav rail, top bar, and the body-portaled minimap), then runs + * tests/helpers/balance_metrics.py to compute OBJECTIVE numbers about how the + * graph is placed: centroid offset from centre, bbox coverage, content usage, + * margin balance, quadrant mass distribution, and an informational balanceScore. + * + * PHASE 1 = measurement, not a verdict. It records numbers + an annotated + * overlay into the report and only asserts that content was detected — so we get + * objective baselines first. Centering/usage THRESHOLDS (pass/fail) come once the + * camera-centering work lands and we know what "good" looks like numerically. + */ + +const PY = path.join(process.cwd(), 'tests/helpers/balance_metrics.py'); +const OUT = path.join(process.cwd(), 'test-artifacts/balance'); +mkdirSync(OUT, { recursive: true }); + +// Graph view is the default at >=768px; phones default to cards (graph-view on a +// 390px phone is a non-standard forced state), so balance is scanned at the +// resolutions where the graph canvas is a real, primary scenario. +const RESOLUTIONS = [ + { name: 'desktop', w: 1440, h: 900 }, + { name: 'laptop', w: 1280, h: 800 }, + { name: 'tablet', w: 768, h: 1024 }, +]; + +test.describe('graph balance metrics (OpenCV) @balance', () => { + test.describe.configure({ timeout: 120_000 }); + + for (const r of RESOLUTIONS) { + test(`balance @${r.name} ${r.w}x${r.h}`, async ({ page }, info) => { + await page.setViewportSize({ width: r.w, height: r.h }); + await login(page, TEST_USERS.ADMIN); + // Render in the flat high-contrast "audit" theme so OpenCV detection is clean + // and deterministic (real render; only decoration is stripped, layout intact). + await page.addInitScript(() => { + localStorage.setItem('graphdone:viewMode', 'graph'); + localStorage.setItem('graphdone:theme', 'contrast'); + }); + await page.goto(`${getBaseURL()}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(3500); // let physics settle + any camera framing run + + // Close the minimap so it doesn't sit in the clip region (it's chrome, not graph content). + await page.locator('button[title="Hide Mini-Map"]').click().catch(() => {}); + await page.waitForTimeout(200); + const canvas = page.locator('.graph-container').first(); + if (!(await canvas.isVisible().catch(() => false))) test.skip(true, 'no graph canvas in this view'); + const img = path.join(OUT, `${r.name}.png`); + const ann = path.join(OUT, `${r.name}.annotated.jpg`); + await canvas.screenshot({ path: img }); + + const m = JSON.parse(execFileSync('python3', [PY, img, '--annotate', ann, '--flat']).toString()); + const c = m.centroid || {}, b = m.bbox || {}, q = m.quadrants || {}; + console.log(`[balance ${r.name}] score=${m.balanceScore} offMag=${c.offMag} (dx=${c.offX},dy=${c.offY}) usage=${m.contentFrac} bboxCov=${b.coverage} quadImb=${q.imbalance}`); + + await info.attach(`balance-${r.name}`, { path: ann, contentType: 'image/jpeg' }); + await info.attach(`metrics-${r.name}`, { body: JSON.stringify(m, null, 2), contentType: 'application/json' }); + + // Phase 1 is measurement, not a gate: record even a near-empty canvas + // (itself a signal the graph rendered off-screen) instead of failing. + // Centering/usage THRESHOLDS become pass/fail once the camera work lands. + expect(m, 'metrics computed').toBeTruthy(); + if ((m.contentPixels ?? 0) < 200) { + console.warn(`[balance ${r.name}] near-empty canvas (${m.contentPixels}px) — graph likely rendered off-screen`); + } + }); + } +}); diff --git a/tests/helpers/balance_metrics.py b/tests/helpers/balance_metrics.py new file mode 100644 index 00000000..0529f17d --- /dev/null +++ b/tests/helpers/balance_metrics.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""OpenCV screen-balance / layout metrics for a graph-canvas screenshot. + +Given an image (ideally already cropped to the graph drawing area), it isolates +the rendered graph content from the calm gradient background via edge density +(robust to a smooth background), then reports OBJECTIVE numeric measurements of +how the content is placed and how much of the canvas it uses: + + centroid offset from center, bounding-box coverage, content pixel usage, + left/right & top/bottom margin balance, quadrant mass distribution, and a + single informational balanceScore (0-100, higher = better-centered/balanced). + +These are measurements, not verdicts — thresholds/pass-fail live in the caller +(Playwright / the live audit), so the same numbers can drive reports first and +gates later. + +Usage: + python3 balance_metrics.py [--annotate ] +Prints a JSON object to stdout. +""" +import json +import sys + +import cv2 +import numpy as np + +MAX_QUAD_STD = 0.4330 # std of [1,0,0,0] — all mass in one quadrant (worst case) + + +def measure(path, annotate=None, force="auto"): + img = cv2.imread(path) + if img is None: + return {"error": f"could not read {path}"} + h, w = img.shape[:2] + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + # Background sampled from a thin border ring. If it's near-uniform (the flat + # high-contrast "audit" theme), detect content by COLOUR DISTANCE from it — + # accurate for solid node fills, not just edges. Otherwise fall back to edge + # density, which is robust against a gradient/textured background. + ring = np.concatenate([ + img[:6].reshape(-1, 3), img[-6:].reshape(-1, 3), + img[:, :6].reshape(-1, 3), img[:, -6:].reshape(-1, 3), + ]).astype(np.int16) + bg = np.median(ring, axis=0) + flat = float(ring.std(axis=0).mean()) < 12.0 + if force == "edge": + flat = False + elif force == "flat": + flat = True + if flat: + diff = np.abs(img.astype(np.int16) - bg).max(axis=2).astype(np.uint8) + mask = ((diff > 28) * 255).astype(np.uint8) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) + else: + edges = cv2.Canny(gray, 50, 140) + mask = cv2.dilate(edges, np.ones((9, 9), np.uint8), iterations=2) + ys, xs = np.where(mask > 0) + + out = {"image": path, "w": w, "h": h, "method": "flat" if flat else "edge", "contentPixels": int(len(xs))} + if len(xs) < 200: # effectively empty canvas + out.update({"empty": True, "balanceScore": None}) + return out + + cx, cy = float(xs.mean()), float(ys.mean()) + off_x = (cx - w / 2) / (w / 2) + off_y = (cy - h / 2) / (h / 2) + off_mag = float((off_x ** 2 + off_y ** 2) ** 0.5) + + x0, x1, y0, y1 = int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max()) + bw, bh = x1 - x0, y1 - y0 + coverage = (bw * bh) / (w * h) # bbox area / canvas area + usage = len(xs) / (w * h) # actual content pixels / canvas + fill = len(xs) / max(1, bw * bh) # content density within its bbox + + left, right, top, bottom = x0, w - 1 - x1, y0, h - 1 - y1 + bal_x = abs(left - right) / max(1, left + right) + bal_y = abs(top - bottom) / max(1, top + bottom) + + q = [ + int(((xs < w / 2) & (ys < h / 2)).sum()), # tl + int(((xs >= w / 2) & (ys < h / 2)).sum()), # tr + int(((xs < w / 2) & (ys >= h / 2)).sum()), # bl + int(((xs >= w / 2) & (ys >= h / 2)).sum()), # br + ] + qf = [v / len(xs) for v in q] + quad_imbalance = float(np.std(qf)) + + center_pen = min(1.0, off_mag) + margin_pen = (bal_x + bal_y) / 2 + quad_pen = min(1.0, quad_imbalance / MAX_QUAD_STD) + score = round(100 * max(0.0, 1 - 0.45 * center_pen - 0.30 * margin_pen - 0.25 * quad_pen)) + + out.update({ + "contentFrac": round(usage, 4), + "centroid": {"x": round(cx, 1), "y": round(cy, 1), + "offX": round(off_x, 3), "offY": round(off_y, 3), "offMag": round(off_mag, 3)}, + "bbox": {"x": x0, "y": y0, "w": bw, "h": bh, + "coverage": round(coverage, 4), "fill": round(fill, 3)}, + "margins": {"left": left, "right": right, "top": top, "bottom": bottom, + "balanceX": round(bal_x, 3), "balanceY": round(bal_y, 3)}, + "quadrants": {"tl": round(qf[0], 3), "tr": round(qf[1], 3), + "bl": round(qf[2], 3), "br": round(qf[3], 3), + "imbalance": round(quad_imbalance, 3)}, + "balanceScore": score, + }) + + if annotate: + vis = img.copy() + cv2.rectangle(vis, (x0, y0), (x1, y1), (80, 200, 80), 2) # content bbox (green) + cv2.drawMarker(vis, (int(cx), int(cy)), (80, 80, 240), cv2.MARKER_CROSS, 26, 3) # centroid (red) + cv2.drawMarker(vis, (w // 2, h // 2), (240, 200, 80), cv2.MARKER_TILTED_CROSS, 22, 2) # frame center (blue) + cv2.line(vis, (w // 2, 0), (w // 2, h), (90, 90, 90), 1) + cv2.line(vis, (0, h // 2), (w, h // 2), (90, 90, 90), 1) + label = f"score {score} | off {off_mag:.2f} | use {usage*100:.0f}% | quad {quad_imbalance:.2f}" + cv2.rectangle(vis, (0, 0), (max(360, 9 * len(label)), 26), (20, 20, 20), -1) + cv2.putText(vis, label, (8, 18), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (240, 240, 240), 1, cv2.LINE_AA) + cv2.imwrite(annotate, vis, [cv2.IMWRITE_JPEG_QUALITY, 80]) + out["annotated"] = annotate + + return out + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(json.dumps({"error": "usage: balance_metrics.py [--annotate ]"})) + sys.exit(2) + ann = None + if "--annotate" in sys.argv: + ann = sys.argv[sys.argv.index("--annotate") + 1] + force = "flat" if "--flat" in sys.argv else "edge" if "--edge" in sys.argv else "auto" + print(json.dumps(measure(sys.argv[1], ann, force)))