Import sap-architecture skill
This commit is contained in:
@@ -0,0 +1,959 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user