Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html data-theme=
"contrast">). 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;
}
11 changes: 11 additions & 0 deletions packages/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
77 changes: 77 additions & 0 deletions tests/diagnostics/graph-balance.spec.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
});
}
});
132 changes: 132 additions & 0 deletions tests/helpers/balance_metrics.py
Original file line number Diff line number Diff line change
@@ -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 <image> [--annotate <out.jpg>]
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 <image> [--annotate <out.jpg>]"}))
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)))
Loading