960 lines
30 KiB
Python
960 lines
30 KiB
Python
#!/usr/bin/env python3
|
||
"""Render a candidate + reference `.drawio` to PNG and produce a side-by-side
|
||
HTML review page with overlay-swipe, hover-zoom, and tabs for the different
|
||
comparison modes.
|
||
|
||
Why this matters:
|
||
The structural fingerprint score (`compare.py`) is necessary but
|
||
insufficient for the last 20% of diagram polish. Two diagrams with
|
||
similar fingerprints can look very different. The honest workflow is:
|
||
|
||
scaffold → manual edit → render → side-by-side compare → iterate.
|
||
|
||
This script collapses "render + compare + review" into one command so
|
||
the human iteration loop is fast.
|
||
|
||
The HTML supports four review modes:
|
||
1. Side-by-side: classic two-pane comparison.
|
||
2. Overlay slider: drag a slider to fade between reference and
|
||
candidate. Catches subtle position/size drifts.
|
||
3. Swipe (curtain): vertical dividing line you drag — left half
|
||
reference, right half candidate. Fastest way to spot zone-level
|
||
differences.
|
||
4. Difference: stacked images with subtraction blend mode (browser-
|
||
native), no Pillow dependency.
|
||
|
||
Usage:
|
||
render_compare.py reference.drawio candidate.drawio
|
||
→ writes review.html plus reference.png + candidate.png next to it
|
||
|
||
render_compare.py reference.drawio candidate.drawio \
|
||
--out-dir .cache/review/agentic-ai/ --open
|
||
|
||
render_compare.py reference.drawio candidate.drawio \
|
||
--scale 1.5 --transparent
|
||
|
||
Exit code:
|
||
0 — review HTML written
|
||
1 — render or compare failed
|
||
2 — usage / draw.io CLI not found
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import html
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
THIS_DIR = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(THIS_DIR))
|
||
|
||
import render as _render # noqa: E402 drawio CLI wrapper
|
||
import compare as _compare # noqa: E402 fingerprint comparison
|
||
|
||
|
||
HTML_TEMPLATE = r"""<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>SAP Diagram Review – __TITLE__</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light;
|
||
--bg: #f5f6f7;
|
||
--card: #ffffff;
|
||
--border: #d5dadd;
|
||
--primary: #0070f2;
|
||
--good: #188918;
|
||
--warn: #c35500;
|
||
--bad: #d20a0a;
|
||
--text: #1d2d3e;
|
||
--muted: #556b82;
|
||
--shadow: 0 2px 8px rgba(29, 45, 62, 0.06);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
font-family: Helvetica, Arial, sans-serif;
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
line-height: 1.45;
|
||
}
|
||
header {
|
||
padding: 16px 24px;
|
||
background: var(--card);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
header h1 {
|
||
margin: 0;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
flex: 1;
|
||
min-width: 280px;
|
||
}
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
font-size: 11px;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
background: var(--bg);
|
||
color: var(--muted);
|
||
border-radius: 4px;
|
||
margin-left: 8px;
|
||
}
|
||
.score-pill {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 10px 18px;
|
||
background: var(--score-bg, var(--bg));
|
||
border: 1px solid var(--score-border, var(--border));
|
||
border-radius: 999px;
|
||
}
|
||
.score-pill .num {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: var(--score-color, var(--text));
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.score-pill .lbl {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.actions a, .actions button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
font: inherit;
|
||
font-size: 13px;
|
||
border: 1px solid var(--border);
|
||
background: var(--card);
|
||
color: var(--text);
|
||
border-radius: 6px;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
transition: background 80ms;
|
||
}
|
||
.actions a:hover, .actions button:hover {
|
||
background: var(--bg);
|
||
}
|
||
.actions a.primary, .actions button.primary {
|
||
background: var(--primary);
|
||
color: white;
|
||
border-color: var(--primary);
|
||
}
|
||
.actions a.primary:hover, .actions button.primary:hover {
|
||
filter: brightness(1.06);
|
||
}
|
||
|
||
nav.modes {
|
||
padding: 0 24px;
|
||
background: var(--card);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
nav.modes button {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 12px 16px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
font-family: inherit;
|
||
}
|
||
nav.modes button.active {
|
||
color: var(--primary);
|
||
border-bottom-color: var(--primary);
|
||
}
|
||
nav.modes button:hover { color: var(--text); }
|
||
|
||
.stage {
|
||
padding: 16px 24px;
|
||
}
|
||
.panel {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Mode: side-by-side */
|
||
.mode { display: none; }
|
||
.mode.active { display: block; }
|
||
.sbs {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
@media (max-width: 1024px) {
|
||
.sbs { grid-template-columns: 1fr; }
|
||
}
|
||
.pane h2 {
|
||
margin: 0;
|
||
padding: 10px 14px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
border-bottom: 1px solid var(--border);
|
||
background: #fafbfc;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.pane h2 .who {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.pane h2 .who::before {
|
||
content: "";
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
}
|
||
.pane h2.ref .who::before { background: var(--good); }
|
||
.pane h2.cand .who::before { background: var(--primary); }
|
||
.pane figure {
|
||
margin: 0;
|
||
padding: 12px;
|
||
background: #fff;
|
||
text-align: center;
|
||
}
|
||
.pane figure img {
|
||
max-width: 100%;
|
||
max-height: 78vh;
|
||
border: 1px solid var(--border);
|
||
cursor: zoom-in;
|
||
}
|
||
|
||
/* Mode: overlay slider */
|
||
.overlay-wrap {
|
||
padding: 14px;
|
||
text-align: center;
|
||
background: #fff;
|
||
}
|
||
.overlay-stage {
|
||
position: relative;
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
}
|
||
.overlay-stage img {
|
||
display: block;
|
||
max-width: 100%;
|
||
max-height: 78vh;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.overlay-stage img.cand {
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
pointer-events: none;
|
||
}
|
||
.overlay-controls {
|
||
padding: 14px 24px 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
justify-content: center;
|
||
background: #fafbfc;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.overlay-controls label {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
.overlay-controls input[type=range] {
|
||
width: 280px;
|
||
}
|
||
.opacity-readout {
|
||
width: 56px;
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Mode: swipe / curtain */
|
||
.swipe-wrap {
|
||
padding: 14px;
|
||
background: #fff;
|
||
text-align: center;
|
||
}
|
||
.swipe-stage {
|
||
position: relative;
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
cursor: ew-resize;
|
||
user-select: none;
|
||
}
|
||
.swipe-stage img {
|
||
display: block;
|
||
max-width: 100%;
|
||
max-height: 78vh;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.swipe-stage .clip {
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
bottom: 0;
|
||
overflow: hidden;
|
||
width: 50%;
|
||
border-right: 2px solid var(--primary);
|
||
pointer-events: none;
|
||
}
|
||
.swipe-stage .clip img {
|
||
max-width: none;
|
||
width: auto;
|
||
}
|
||
.swipe-handle {
|
||
position: absolute;
|
||
top: 50%; transform: translateY(-50%);
|
||
width: 32px; height: 32px;
|
||
background: var(--primary);
|
||
color: white;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
cursor: ew-resize;
|
||
box-shadow: 0 2px 8px rgba(0, 112, 242, 0.4);
|
||
pointer-events: auto;
|
||
}
|
||
.swipe-labels {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 8px 24px;
|
||
font-size: 11px;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
background: #fafbfc;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
/* Mode: difference (CSS blend) */
|
||
.diff-wrap {
|
||
padding: 14px;
|
||
background: #1d2d3e;
|
||
text-align: center;
|
||
}
|
||
.diff-stage {
|
||
position: relative;
|
||
display: inline-block;
|
||
}
|
||
.diff-stage img {
|
||
max-width: 100%;
|
||
max-height: 78vh;
|
||
display: block;
|
||
}
|
||
.diff-stage .top {
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
mix-blend-mode: difference;
|
||
}
|
||
.diff-note {
|
||
padding: 12px 24px;
|
||
background: #1d2d3e;
|
||
color: #d5dadd;
|
||
font-size: 12px;
|
||
text-align: center;
|
||
border-top: 1px solid #354a5f;
|
||
}
|
||
|
||
/* Score breakdown */
|
||
.breakdown {
|
||
padding: 18px 24px 8px;
|
||
}
|
||
.breakdown h3 {
|
||
margin: 0 0 10px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
}
|
||
.breakdown table {
|
||
border-collapse: collapse;
|
||
width: 100%;
|
||
max-width: 920px;
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
.breakdown th, .breakdown td {
|
||
padding: 9px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 13px;
|
||
text-align: left;
|
||
}
|
||
.breakdown th {
|
||
background: #fafbfc;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
font-size: 11px;
|
||
}
|
||
.breakdown td.score {
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
width: 80px;
|
||
font-weight: 600;
|
||
}
|
||
.breakdown td.bar-cell {
|
||
width: 280px;
|
||
}
|
||
.bar {
|
||
height: 7px;
|
||
background: #eaecee;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
.bar > span {
|
||
display: block;
|
||
height: 100%;
|
||
transition: width 200ms;
|
||
}
|
||
.bar > span.good { background: var(--good); }
|
||
.bar > span.fine { background: var(--primary); }
|
||
.bar > span.warn { background: var(--warn); }
|
||
.bar > span.bad { background: var(--bad); }
|
||
|
||
/* Suggestions and diffs */
|
||
.insights {
|
||
padding: 18px 24px 28px;
|
||
max-width: 1180px;
|
||
}
|
||
.insights h3 {
|
||
margin: 18px 0 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
}
|
||
.insights ul {
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
}
|
||
.insights li {
|
||
padding: 12px 14px;
|
||
margin-bottom: 8px;
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-left: 3px solid var(--primary);
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
}
|
||
.insights li.diff {
|
||
border-left-color: var(--warn);
|
||
}
|
||
.insights code {
|
||
background: #f5f6f7;
|
||
padding: 1px 6px;
|
||
border-radius: 3px;
|
||
font-size: 12px;
|
||
font-family: Menlo, Consolas, monospace;
|
||
}
|
||
.insights .empty {
|
||
color: var(--muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Lightbox */
|
||
.lightbox {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(29, 45, 62, 0.85);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: zoom-out;
|
||
z-index: 50;
|
||
}
|
||
.lightbox.active { display: flex; }
|
||
.lightbox img {
|
||
max-width: 95vw;
|
||
max-height: 95vh;
|
||
}
|
||
|
||
/* Meta footer */
|
||
.meta {
|
||
padding: 14px 24px 30px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.meta p {
|
||
margin: 4px 0;
|
||
}
|
||
.meta code {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
padding: 1px 6px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>SAP Diagram Review · __TITLE__ <span class="badge">__BADGE_LABEL__</span></h1>
|
||
<div class="score-pill" style="--score-color: __SCORE_COLOR__; --score-bg: __SCORE_BG__; --score-border: __SCORE_BORDER__">
|
||
<span class="num">__SCORE__</span>
|
||
<span class="lbl">structural fidelity / 100</span>
|
||
</div>
|
||
<div class="actions">
|
||
<a class="primary" href="__CAND_DRAWIO_URI__" title="Open the candidate in draw.io desktop">Open candidate in draw.io</a>
|
||
<a href="__REF_DRAWIO_URI__" title="Open the reference template in draw.io desktop">Open reference</a>
|
||
</div>
|
||
</header>
|
||
|
||
<nav class="modes" id="mode-nav">
|
||
<button data-mode="sbs" class="active">Side-by-side</button>
|
||
<button data-mode="overlay">Overlay slider</button>
|
||
<button data-mode="swipe">Swipe</button>
|
||
<button data-mode="diff">Difference</button>
|
||
</nav>
|
||
|
||
<div class="stage">
|
||
<!-- Side-by-side -->
|
||
<div class="mode active" id="m-sbs">
|
||
<div class="sbs">
|
||
<section class="panel pane">
|
||
<h2 class="ref"><span class="who">Reference (target)</span><span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:11px">__REF_NAME__</span></h2>
|
||
<figure><img src="__REF_IMG__" alt="reference" data-zoom></figure>
|
||
</section>
|
||
<section class="panel pane">
|
||
<h2 class="cand"><span class="who">Candidate (your diagram)</span><span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:11px">__CAND_NAME__</span></h2>
|
||
<figure><img src="__CAND_IMG__" alt="candidate" data-zoom></figure>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Overlay slider -->
|
||
<div class="mode" id="m-overlay">
|
||
<section class="panel">
|
||
<div class="overlay-wrap">
|
||
<div class="overlay-stage">
|
||
<img src="__REF_IMG__" alt="reference">
|
||
<img src="__CAND_IMG__" alt="candidate" class="cand" id="overlay-cand" style="opacity: 0.5">
|
||
</div>
|
||
</div>
|
||
<div class="overlay-controls">
|
||
<label>Reference</label>
|
||
<input type="range" min="0" max="100" value="50" id="overlay-slider">
|
||
<label>Candidate</label>
|
||
<span class="opacity-readout" id="overlay-readout">50%</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- Swipe / curtain -->
|
||
<div class="mode" id="m-swipe">
|
||
<section class="panel">
|
||
<div class="swipe-wrap">
|
||
<div class="swipe-stage" id="swipe-stage">
|
||
<img src="__CAND_IMG__" alt="candidate" id="swipe-bg">
|
||
<div class="clip" id="swipe-clip">
|
||
<img src="__REF_IMG__" alt="reference" id="swipe-fg">
|
||
</div>
|
||
<div class="swipe-handle" id="swipe-handle">↔</div>
|
||
</div>
|
||
</div>
|
||
<div class="swipe-labels">
|
||
<span>Reference (left of line)</span>
|
||
<span>Candidate (right of line)</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- Difference (CSS blend) -->
|
||
<div class="mode" id="m-diff">
|
||
<section class="panel">
|
||
<div class="diff-wrap">
|
||
<div class="diff-stage">
|
||
<img src="__REF_IMG__" alt="reference">
|
||
<img src="__CAND_IMG__" alt="candidate" class="top">
|
||
</div>
|
||
</div>
|
||
<p class="diff-note">Black areas = identical pixels. Bright areas = differences. Uses the browser's <code>mix-blend-mode: difference</code>.</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="breakdown">
|
||
<h3>Score breakdown — what to fix in priority order</h3>
|
||
<table>
|
||
<thead><tr><th>Dimension</th><th class="score">Score</th><th class="bar-cell">Bar</th></tr></thead>
|
||
<tbody>
|
||
__BREAKDOWN_ROWS__
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="insights">
|
||
<h3>Actionable suggestions</h3>
|
||
<ul>
|
||
__ACTIONABLE_BLOCK__
|
||
</ul>
|
||
<h3>Notable structural diffs</h3>
|
||
__DIFFS_BLOCK__
|
||
</section>
|
||
|
||
<section class="meta">
|
||
<p><strong>Reference</strong> · <code>__REF_PATH__</code></p>
|
||
<p><strong>Candidate</strong> · <code>__CAND_PATH__</code></p>
|
||
<p>Generated <code>__TIMESTAMP__</code>. After editing the candidate in draw.io desktop, re-run <code>render_compare.py</code> to refresh this review.</p>
|
||
</section>
|
||
|
||
<div class="lightbox" id="lightbox"><img id="lightbox-img" alt="zoomed"></div>
|
||
|
||
<script>
|
||
// Mode switcher
|
||
document.getElementById('mode-nav').addEventListener('click', e => {
|
||
if (e.target.tagName !== 'BUTTON') return;
|
||
const target = e.target.dataset.mode;
|
||
document.querySelectorAll('#mode-nav button').forEach(b => b.classList.toggle('active', b.dataset.mode === target));
|
||
document.querySelectorAll('.mode').forEach(m => m.classList.toggle('active', m.id === 'm-' + target));
|
||
});
|
||
|
||
// Overlay slider
|
||
const overlaySlider = document.getElementById('overlay-slider');
|
||
const overlayCand = document.getElementById('overlay-cand');
|
||
const overlayReadout = document.getElementById('overlay-readout');
|
||
overlaySlider.addEventListener('input', () => {
|
||
const v = overlaySlider.value;
|
||
overlayCand.style.opacity = v / 100;
|
||
overlayReadout.textContent = v + '%';
|
||
});
|
||
|
||
// Swipe / curtain — keep reference image fully sized in the clip; resize on window/image load
|
||
const swipeStage = document.getElementById('swipe-stage');
|
||
const swipeClip = document.getElementById('swipe-clip');
|
||
const swipeFg = document.getElementById('swipe-fg');
|
||
const swipeBg = document.getElementById('swipe-bg');
|
||
const swipeHandle = document.getElementById('swipe-handle');
|
||
|
||
function syncSwipeFgSize() {
|
||
const r = swipeBg.getBoundingClientRect();
|
||
swipeFg.style.width = r.width + 'px';
|
||
swipeFg.style.height = r.height + 'px';
|
||
}
|
||
swipeBg.addEventListener('load', syncSwipeFgSize);
|
||
window.addEventListener('resize', syncSwipeFgSize);
|
||
|
||
let dragging = false;
|
||
function setSwipe(clientX) {
|
||
const r = swipeStage.getBoundingClientRect();
|
||
let pct = ((clientX - r.left) / r.width) * 100;
|
||
pct = Math.max(0, Math.min(100, pct));
|
||
swipeClip.style.width = pct + '%';
|
||
swipeHandle.style.left = pct + '%';
|
||
}
|
||
swipeStage.addEventListener('mousedown', e => { dragging = true; setSwipe(e.clientX); e.preventDefault(); });
|
||
window.addEventListener('mousemove', e => { if (dragging) setSwipe(e.clientX); });
|
||
window.addEventListener('mouseup', () => { dragging = false; });
|
||
swipeStage.addEventListener('touchstart', e => { dragging = true; setSwipe(e.touches[0].clientX); }, { passive: true });
|
||
window.addEventListener('touchmove', e => { if (dragging) setSwipe(e.touches[0].clientX); }, { passive: true });
|
||
window.addEventListener('touchend', () => { dragging = false; });
|
||
// Initialize handle position
|
||
window.addEventListener('load', () => {
|
||
syncSwipeFgSize();
|
||
const r = swipeStage.getBoundingClientRect();
|
||
swipeHandle.style.left = '50%';
|
||
});
|
||
|
||
// Lightbox click-to-zoom on side-by-side images
|
||
const lightbox = document.getElementById('lightbox');
|
||
const lightboxImg = document.getElementById('lightbox-img');
|
||
document.querySelectorAll('img[data-zoom]').forEach(img => {
|
||
img.addEventListener('click', () => {
|
||
lightboxImg.src = img.src;
|
||
lightbox.classList.add('active');
|
||
});
|
||
});
|
||
lightbox.addEventListener('click', () => lightbox.classList.remove('active'));
|
||
|
||
// Keyboard shortcuts: 1/2/3/4 to switch modes, Esc closes lightbox
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') lightbox.classList.remove('active');
|
||
const idx = ['1', '2', '3', '4'].indexOf(e.key);
|
||
if (idx >= 0) {
|
||
const modes = ['sbs', 'overlay', 'swipe', 'diff'];
|
||
document.querySelector(`#mode-nav button[data-mode=${modes[idx]}]`).click();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def score_color(score: float) -> tuple[str, str, str]:
|
||
"""Return (color, bg, border) tuple for the score pill."""
|
||
if score >= 95:
|
||
return ("#188918", "#F5FAE5", "#C6E5C8")
|
||
if score >= 85:
|
||
return ("#0070F2", "#EBF8FF", "#B8DCFC")
|
||
if score >= 70:
|
||
return ("#C35500", "#FFF8D6", "#F2D8A6")
|
||
return ("#D20A0A", "#FFEAF4", "#F5C5C0")
|
||
|
||
|
||
def bar_class(pct: float) -> str:
|
||
if pct >= 95:
|
||
return "good"
|
||
if pct >= 80:
|
||
return "fine"
|
||
if pct >= 60:
|
||
return "warn"
|
||
return "bad"
|
||
|
||
|
||
def actionable_suggestions(breakdown: dict, ref_fp, cand_fp, diffs: list[str]) -> list[str]:
|
||
"""Map low-scoring fingerprint dimensions to concrete edit suggestions.
|
||
|
||
Each rule is "if dimension X is below threshold, suggest a specific
|
||
edit." This is the bridge from raw scoring to human-actionable advice.
|
||
"""
|
||
out: list[str] = []
|
||
if breakdown.get("page_bg", 1.0) < 1.0:
|
||
out.append(
|
||
"Set the canvas to white or transparent — remove "
|
||
f"<code>pageBackgroundColor=\"{cand_fp.page_background or '?'}\"</code>"
|
||
" from <code><mxGraphModel></code>. SAP diagrams are always on white."
|
||
)
|
||
if breakdown.get("canvas", 1.0) < 1.0:
|
||
out.append(
|
||
f"Resize canvas to <code>{ref_fp.canvas_w}×{ref_fp.canvas_h}</code> "
|
||
f"(currently <code>{cand_fp.canvas_w}×{cand_fp.canvas_h}</code>). "
|
||
"In draw.io: <em>Diagram → Page Setup → Custom</em>."
|
||
)
|
||
if breakdown.get("zones", 1.0) < 0.7:
|
||
delta = abs(ref_fp.zones - cand_fp.zones)
|
||
verb = "add" if cand_fp.zones < ref_fp.zones else "remove"
|
||
out.append(
|
||
f"Zone count differs (ref={ref_fp.zones}, cand={cand_fp.zones}). "
|
||
f"{verb.capitalize()} {delta} rounded-corner area container(s) "
|
||
"(<code>arcSize=16</code>, <code>strokeWidth=1.5</code>)."
|
||
)
|
||
if breakdown.get("zone_depth", 1.0) < 1.0:
|
||
out.append(
|
||
f"Zone nesting depth differs (ref={ref_fp.zone_depth}, cand={cand_fp.zone_depth}). "
|
||
"Watch for cases where a focus zone (Joule, Identity Services) is nested inside BTP "
|
||
"when the SAP reference puts them side-by-side."
|
||
)
|
||
if breakdown.get("icons", 1.0) < 0.7:
|
||
delta = abs(ref_fp.icons - cand_fp.icons)
|
||
verb = "add" if cand_fp.icons < ref_fp.icons else "remove"
|
||
out.append(
|
||
f"Icon count differs (ref={ref_fp.icons}, cand={cand_fp.icons}). "
|
||
f"{verb.capitalize()} {delta} BTP service icon(s) using "
|
||
"<code>scripts/extract_icon.py \"<service>\" --x <X> --y <Y></code>."
|
||
)
|
||
if breakdown.get("pill_vocab", 1.0) < 1.0:
|
||
if cand_fp.novelty_pill_count:
|
||
out.append(
|
||
f"Replace {cand_fp.novelty_pill_count} novelty pill verb(s). "
|
||
"SAP-canonical pill labels: <code>TRUST</code>, <code>Authenticate</code>, "
|
||
"<code>Authorization</code>, <code>A2A</code>, <code>MCP</code>, "
|
||
"<code>ORD</code>, <code>HTTPS</code>, <code>OData/REST</code>, "
|
||
"<code>SAML2/OIDC</code>, <code>SCIM</code>. Avoid "
|
||
"PROMPT/ROUTE/CONTEXT/DELEGATE/INVOKE/FETCH."
|
||
)
|
||
if breakdown.get("edge_palette", 1.0) < 0.6:
|
||
missing = sorted(set(ref_fp.edge_palette) - set(cand_fp.edge_palette))
|
||
if missing:
|
||
swatch = " ".join(
|
||
f'<span style="display:inline-block;width:14px;height:14px;background:{c};'
|
||
f'border-radius:3px;border:1px solid var(--border);vertical-align:middle"></span>'
|
||
f' <code>{c}</code>'
|
||
for c in missing[:6]
|
||
)
|
||
out.append(
|
||
f"Add SAP-mandated connector colors on edges: {swatch}. "
|
||
"trust=<code>#CC00DC</code> pink · auth=<code>#188918</code> green · "
|
||
"authorization=<code>#5D36FF</code> indigo · structural=<code>#475E75</code> slate."
|
||
)
|
||
if breakdown.get("label_tokens", 1.0) < 0.6:
|
||
out.append(
|
||
"Label-token Jaccard is low — visible text drifted from the reference. "
|
||
"Restore or rename service-card labels to match the SAP scenario vocabulary."
|
||
)
|
||
if breakdown.get("grid_snap", 1.0) < 0.9:
|
||
out.append(
|
||
"Geometry off the 10-px grid. Run "
|
||
"<code>autofix.py --write <file></code> to snap automatically."
|
||
)
|
||
# Surface raw diffs we couldn't classify into specific advice
|
||
for d in diffs[:3]:
|
||
if not any(d in o for o in out):
|
||
out.append(html.escape(d))
|
||
if not out:
|
||
out.append("Looks structurally close. Use the <strong>Swipe</strong> or "
|
||
"<strong>Difference</strong> tabs above to spot subtle visual drifts.")
|
||
return out
|
||
|
||
|
||
def to_file_uri(path: Path) -> str:
|
||
"""Convert an absolute path to a file:// URI for browser links."""
|
||
p = path.resolve()
|
||
# macOS/Linux: prepend /// (file:///abs/path)
|
||
return "file://" + str(p)
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("reference", type=Path)
|
||
ap.add_argument("candidate", type=Path)
|
||
ap.add_argument("--out-dir", type=Path, default=None,
|
||
help="output dir for the HTML and PNGs (default: alongside candidate)")
|
||
ap.add_argument("--scale", type=float, default=1.0)
|
||
ap.add_argument("--border", type=int, default=10)
|
||
ap.add_argument("--transparent", action="store_true")
|
||
ap.add_argument("--open", action="store_true", help="open the HTML in default browser")
|
||
args = ap.parse_args()
|
||
|
||
if not args.reference.exists():
|
||
print(f"reference {args.reference}: not found", file=sys.stderr)
|
||
return 1
|
||
if not args.candidate.exists():
|
||
print(f"candidate {args.candidate}: not found", file=sys.stderr)
|
||
return 1
|
||
|
||
cli = _render.find_drawio_cli()
|
||
if not cli:
|
||
print(
|
||
"draw.io CLI not found — install draw.io desktop or set $DRAWIO_CLI.",
|
||
file=sys.stderr,
|
||
)
|
||
return 2
|
||
|
||
out_dir = args.out_dir or args.candidate.parent
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
ref_png = out_dir / f"{args.reference.stem}.reference.png"
|
||
cand_png = out_dir / f"{args.candidate.stem}.candidate.png"
|
||
|
||
rc = _render.render_one(cli, args.reference, ref_png, "png", args.scale, args.border, args.transparent, quiet=False)
|
||
if rc != 0:
|
||
print(f"reference render failed (rc={rc})", file=sys.stderr)
|
||
return 1
|
||
rc = _render.render_one(cli, args.candidate, cand_png, "png", args.scale, args.border, args.transparent, quiet=False)
|
||
if rc != 0:
|
||
print(f"candidate render failed (rc={rc})", file=sys.stderr)
|
||
return 1
|
||
|
||
ref_fp = _compare.fingerprint(args.reference)
|
||
cand_fp = _compare.fingerprint(args.candidate)
|
||
result = _compare.compare(ref_fp, cand_fp)
|
||
|
||
breakdown_rows: list[str] = []
|
||
for k, v in sorted(result.breakdown.items(), key=lambda kv: kv[1]):
|
||
pct = v * 100
|
||
cls = bar_class(pct)
|
||
breakdown_rows.append(
|
||
f'<tr><td>{html.escape(k)}</td>'
|
||
f'<td class="score">{pct:.1f}</td>'
|
||
f'<td class="bar-cell"><div class="bar"><span class="{cls}" style="width: {pct:.1f}%"></span></div></td></tr>'
|
||
)
|
||
|
||
if result.diffs:
|
||
diffs_html = "<ul>"
|
||
for d in result.diffs:
|
||
diffs_html += f'<li class="diff">{html.escape(d)}</li>'
|
||
diffs_html += "</ul>"
|
||
else:
|
||
diffs_html = '<ul><li class="empty">No notable structural differences detected.</li></ul>'
|
||
|
||
actionable = actionable_suggestions(result.breakdown, ref_fp, cand_fp, result.diffs)
|
||
actionable_html = "\n".join(f" <li>{a}</li>" for a in actionable)
|
||
|
||
color, bg, border = score_color(result.score)
|
||
|
||
badge_label = "PASS ≥ 90" if result.score >= 90 else (
|
||
"Near-miss" if result.score >= 80 else (
|
||
"Below 80 — needs structural work" if result.score >= 60 else "Far from target"
|
||
)
|
||
)
|
||
|
||
placeholders = {
|
||
"TITLE": html.escape(args.candidate.stem),
|
||
"BADGE_LABEL": html.escape(badge_label),
|
||
"SCORE": f"{result.score:.1f}",
|
||
"SCORE_COLOR": color,
|
||
"SCORE_BG": bg,
|
||
"SCORE_BORDER": border,
|
||
"REF_NAME": html.escape(args.reference.name),
|
||
"CAND_NAME": html.escape(args.candidate.name),
|
||
"REF_IMG": html.escape(ref_png.name),
|
||
"CAND_IMG": html.escape(cand_png.name),
|
||
"REF_DRAWIO_URI": html.escape(to_file_uri(args.reference)),
|
||
"CAND_DRAWIO_URI": html.escape(to_file_uri(args.candidate)),
|
||
"REF_PATH": html.escape(str(args.reference)),
|
||
"CAND_PATH": html.escape(str(args.candidate)),
|
||
"TIMESTAMP": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||
"BREAKDOWN_ROWS": "\n".join(breakdown_rows),
|
||
"DIFFS_BLOCK": diffs_html,
|
||
"ACTIONABLE_BLOCK": actionable_html,
|
||
}
|
||
|
||
html_out = HTML_TEMPLATE
|
||
for key, val in placeholders.items():
|
||
html_out = html_out.replace(f"__{key}__", val)
|
||
|
||
review_html = out_dir / "review.html"
|
||
review_html.write_text(html_out, encoding="utf-8")
|
||
|
||
summary = {
|
||
"reference": str(args.reference),
|
||
"candidate": str(args.candidate),
|
||
"score": result.score,
|
||
"breakdown": result.breakdown,
|
||
"diffs": result.diffs,
|
||
"ref_image": str(ref_png),
|
||
"cand_image": str(cand_png),
|
||
"review_html": str(review_html),
|
||
}
|
||
(out_dir / "review.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||
|
||
print(f"score : {result.score:.1f}/100 ({badge_label})")
|
||
print(f"reference : {ref_png}")
|
||
print(f"candidate : {cand_png}")
|
||
print(f"review : {review_html}")
|
||
print(f" open with: open {review_html}")
|
||
if args.open:
|
||
try:
|
||
subprocess.run(["open", str(review_html)], check=False)
|
||
except FileNotFoundError:
|
||
pass
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|