Skip to content

Commit 77f0093

Browse files
iOSDevSKclaude
andcommitted
gamma.1: the owner's side-by-side comparison on demand, visual-compare.py (from 7379c3d)
Not released: local branch for the Mac app's pin; the release is the lead's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xf7wTz9XZFZ1GCWzDzHaWW
1 parent 0ff2f9f commit 77f0093

3 files changed

Lines changed: 330 additions & 0 deletions

File tree

‎skills/html2wp/SKILL.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3297,6 +3297,13 @@ assets/scripts/audit-woo-coverage.py stage 5.6, MANDATORY for shops: shops the
32973297
per-product facts, search, account, reviews.
32983298
Store-API-driven; no per-site edits needed
32993299
assets/scripts/compare-pages.py stage 5.5 (side-by-side pairs for the read)
3300+
assets/scripts/visual-compare.py the owner's side-by-side look ON DEMAND (a
3301+
UI's Compare button, no model turn):
3302+
compare-pages.py at 1440 and 390 against
3303+
the preview (brought back up if it is
3304+
down), an index with a diff % per page and
3305+
its own status.json; touches nothing of
3306+
the run
33003307
assets/scripts/make-zip.sh stage 6 (clean, installable theme ZIP; PHP
33013308
lint, screenshot 1200x900, bundle present —
33023309
pass MAKE_ZIP_MANIFEST= for the source
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env python3
2+
"""visual-compare.py: the owner's side-by-side look, on demand, for a UI.
3+
4+
- Every page of the manifest is captured from the source and from the
5+
WordPress address at 1440 and at 390 (compare-pages.py composites), and the
6+
index lists each page's key, title, route, both images and a diff %: the
7+
same page on both sides reads 0%, a changed page more.
8+
- status.json goes running → done; nothing of the run is touched (no
9+
progress.json, no result.json).
10+
- With no manifest, or no preview WordPress and no address, it fails in its
11+
own status and exit code only.
12+
- The diff % counts the rows one side has and the other lacks.
13+
14+
A stand-in "WordPress" (a static server) answers the pages, so no Docker is
15+
needed; Playwright is (skipped without it).
16+
17+
python3 test-visual-compare.py
18+
"""
19+
import functools
20+
import importlib.util
21+
import json
22+
import subprocess
23+
import sys
24+
import tempfile
25+
import threading
26+
import unittest
27+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
28+
from pathlib import Path
29+
30+
HERE = Path(__file__).resolve().parent
31+
SCRIPT = HERE / 'visual-compare.py'
32+
spec = importlib.util.spec_from_file_location('visual_compare', SCRIPT)
33+
vc = importlib.util.module_from_spec(spec)
34+
spec.loader.exec_module(vc)
35+
36+
PAGE = ('<!doctype html><html><head><title>{t}</title><style>body{{margin:0;font:20px sans-serif}}'
37+
'section{{height:600px;background:{bg}}}</style></head><body><h1>{t}</h1><section></section></body></html>')
38+
39+
40+
def composite(path, left, right, width=100):
41+
from PIL import Image
42+
board = Image.new('RGB', (2 * width + vc.GAP, vc.CAPTION + max(left[1], right[1])), (24, 24, 24))
43+
board.paste(Image.new('RGB', (width, left[1]), left[0]), (0, vc.CAPTION))
44+
board.paste(Image.new('RGB', (width, right[1]), right[0]), (width + vc.GAP, vc.CAPTION))
45+
board.save(path)
46+
return path
47+
48+
49+
class Diff(unittest.TestCase):
50+
def test_the_diff_percent(self):
51+
with tempfile.TemporaryDirectory() as tmp:
52+
same = composite(Path(tmp) / 's.png', ((250, 250, 250), 200), ((250, 250, 250), 200))
53+
self.assertEqual(vc.diff_percent(same, 100, (200, 200)), 0.0)
54+
other = composite(Path(tmp) / 'o.png', ((250, 250, 250), 200), ((10, 10, 200), 200))
55+
self.assertEqual(vc.diff_percent(other, 100, (200, 200)), 100.0)
56+
taller = composite(Path(tmp) / 't.png', ((250, 250, 250), 200), ((250, 250, 250), 400))
57+
self.assertEqual(vc.diff_percent(taller, 100, (200, 400)), 50.0)
58+
59+
60+
@unittest.skipUnless(importlib.util.find_spec('playwright'), 'playwright is not installed')
61+
class OnDemand(unittest.TestCase):
62+
def setUp(self):
63+
self.tmp = tempfile.TemporaryDirectory()
64+
root = Path(self.tmp.name)
65+
self.ws, src, wp = root / 'work space', root / 'source', root / 'wordpress'
66+
for d in (self.ws, src, wp / 'about'):
67+
d.mkdir(parents=True)
68+
(src / 'index.html').write_text(PAGE.format(t='Home', bg='#eee'))
69+
(src / 'about.html').write_text(PAGE.format(t='About', bg='#eee'))
70+
(wp / 'index.html').write_text(PAGE.format(t='Home', bg='#eee'))
71+
(wp / 'about' / 'index.html').write_text(PAGE.format(t='About', bg='#c33'))
72+
(self.ws / 'conversion-manifest.json').write_text(json.dumps({
73+
'schema': 'html2wp/1', 'site': {'name': 'T', 'slug': 't'}, 'input': {'dir': str(src)},
74+
'workspace': str(self.ws), 'pages': [
75+
{'file': 'index.html', 'key': 'front-page', 'kind': 'front', 'title': 'Home', 'chrome': 'consensus'},
76+
{'file': 'about.html', 'key': 'about', 'kind': 'page', 'title': 'About', 'chrome': 'consensus'}]}))
77+
handler = functools.partial(type('Q', (SimpleHTTPRequestHandler,), {'log_message': lambda *a: None}), directory=str(wp))
78+
self.httpd = ThreadingHTTPServer(('127.0.0.1', 0), handler)
79+
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
80+
self.wp = f'http://127.0.0.1:{self.httpd.server_port}'
81+
82+
def tearDown(self):
83+
self.httpd.shutdown()
84+
self.httpd.server_close()
85+
self.tmp.cleanup()
86+
87+
def run_it(self, *extra):
88+
return subprocess.run([sys.executable, str(SCRIPT), str(self.ws), '--jobs', '1', *extra],
89+
capture_output=True, text=True, timeout=600)
90+
91+
def test_every_page_at_both_widths_with_an_index_and_a_status(self):
92+
done = self.run_it('--wp', self.wp)
93+
self.assertEqual(done.returncode, 0, done.stdout + done.stderr)
94+
status = json.loads((self.ws / 'visual-review' / 'status.json').read_text())
95+
self.assertEqual((status['state'], status['index']), ('done', 'visual-review/visual-compare.json'))
96+
index = json.loads((self.ws / 'visual-review' / 'visual-compare.json').read_text())
97+
pages = {p['key']: p for p in index['pages']}
98+
self.assertEqual(set(pages), {'front-page', 'about'})
99+
self.assertEqual((pages['about']['title'], pages['about']['route']), ('About', self.wp + '/about/'))
100+
for key in pages:
101+
for width in ('desktop', 'mobile'):
102+
self.assertTrue((self.ws / pages[key][width]['image']).is_file(), (key, width))
103+
self.assertEqual(pages['about']['mobile']['image'], 'visual-review/mobile/about.side-by-side.png')
104+
self.assertLess(pages['front-page']['desktop']['diffPercent'], 1.0)
105+
self.assertGreater(pages['about']['desktop']['diffPercent'], 20.0)
106+
self.assertFalse((self.ws / 'progress.json').exists())
107+
self.assertFalse((self.ws / 'result.json').exists())
108+
109+
def test_it_fails_in_its_own_status_only(self):
110+
done = self.run_it() # no --wp and no preview WordPress for this workspace
111+
self.assertEqual(done.returncode, 1)
112+
status = json.loads((self.ws / 'visual-review' / 'status.json').read_text())
113+
self.assertEqual(status['state'], 'failed')
114+
self.assertIn('no preview WordPress', status['note'])
115+
(self.ws / 'conversion-manifest.json').unlink()
116+
self.assertEqual(self.run_it('--wp', self.wp).returncode, 1)
117+
self.assertIn('nothing converted', json.loads((self.ws / 'visual-review' / 'status.json').read_text())['note'])
118+
119+
120+
if __name__ == '__main__':
121+
unittest.main()
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026 BELNEM s.r.o. html2wp Source-Available Licence — see LICENSE.
3+
"""The owner's look: every source page beside its WordPress page, on demand.
4+
5+
visual-compare.py {workspace} [--desktop-only] [--jobs N] [--wp URL]
6+
7+
A UI's "Compare" button runs this directly in the project container — no AI
8+
turn. It is compare-pages.py (stage 5.5's side-by-side composites) run for the
9+
preview WordPress the conversion installed, at 1440 and at 390, with an index
10+
a UI can list and a status file it can poll:
11+
12+
{workspace}/visual-review/<key>.side-by-side.png desktop (1440)
13+
{workspace}/visual-review/mobile/<key>.side-by-side.png mobile (390)
14+
{workspace}/visual-review/visual-compare.json index: key, title, page,
15+
route, images, diff %, heights
16+
{workspace}/visual-review/status.json running | done | failed
17+
18+
The diff % is read off each composite (the share of pixels that differ, the
19+
height difference counted as differing): a hint for which page to open first,
20+
never a gate — the gates are the verdict, and this changes nothing of the
21+
run: not progress.json, not result.json, not a report.
22+
23+
The preview is the one test-env.sh started for this workspace (its state file
24+
names it). Down — a stopped container, a restarted project container — it is
25+
brought back with `test-env.sh up <slug>`, which reuses the same WordPress and
26+
its content. With no preview at all (the theme was never installed) there is
27+
nothing to compare, and the status says so. Visual Edit Lite loads public
28+
scripts, so it is switched off for the capture and back on after, as every
29+
gate does. --wp compares against another address (and skips test-env).
30+
31+
Exit 0 = done; 1 = failed (status.json says why); 2 = usage. A second run while
32+
one is going is refused (exit 1) rather than raced.
33+
"""
34+
import argparse
35+
import json
36+
import os
37+
import shlex
38+
import subprocess
39+
import sys
40+
import time
41+
import urllib.error
42+
import urllib.request
43+
from pathlib import Path
44+
45+
HERE = Path(__file__).resolve().parent
46+
SCHEMA = "h2wp-visual-compare/1"
47+
CAPTION, GAP = 44, 24 # compare-pages.py compose(): caption band, gutter
48+
WIDTHS = (("desktop", 1440, ""), ("mobile", 390, "mobile"))
49+
50+
51+
def now():
52+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
53+
54+
55+
def write_json(path, doc):
56+
path.parent.mkdir(parents=True, exist_ok=True)
57+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
58+
tmp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
59+
os.replace(tmp, path)
60+
61+
62+
def diff_percent(composite, width, heights):
63+
"""The share of the page that differs between the two halves of a
64+
compare-pages.py composite (original left, WordPress right, each
65+
`heights` tall): pixels whose colour moved by more than a little, plus
66+
the rows one side has and the other does not."""
67+
from PIL import Image, ImageChops
68+
board = Image.open(composite).convert("RGB")
69+
left_h, right_h = heights
70+
common, tall = min(left_h, right_h), max(left_h, right_h) or 1
71+
if common <= 0:
72+
return 100.0
73+
a = board.crop((0, CAPTION, width, CAPTION + common))
74+
b = board.crop((width + GAP, CAPTION, 2 * width + GAP, CAPTION + common))
75+
moved = ImageChops.difference(a, b).convert("L").point(lambda v: 255 if v > 24 else 0)
76+
changed = moved.histogram()[255] + (tall - common) * width
77+
return round(100.0 * changed / (tall * width), 2)
78+
79+
80+
def answers(url):
81+
try:
82+
with urllib.request.urlopen(urllib.request.Request(url, method="HEAD"), timeout=10) as r:
83+
return r.status < 500
84+
except urllib.error.HTTPError as e:
85+
return e.code < 500
86+
except OSError:
87+
return False
88+
89+
90+
def preview(ws, slug, status):
91+
"""(url, wp-cli prefix) of the workspace's preview, up."""
92+
state_path = ws / f".test-env-{slug}.json"
93+
if not state_path.is_file():
94+
raise RuntimeError("no preview WordPress for this project yet: the theme was never installed "
95+
"(the conversion starts one at stage 3)")
96+
state = json.loads(state_path.read_text())
97+
if not answers(state["url"] + "/"):
98+
status("bringing the preview WordPress back up")
99+
up = subprocess.run(["bash", str(HERE / "test-env.sh"), "up", slug], cwd=ws,
100+
capture_output=True, text=True, timeout=900)
101+
state = json.loads(state_path.read_text())
102+
if up.returncode != 0 or not answers(state["url"] + "/"):
103+
tail = (up.stdout + up.stderr).strip().splitlines()[-3:]
104+
raise RuntimeError("the preview WordPress did not come back: " + " | ".join(tail))
105+
return state["url"], state.get("wpCli") or ""
106+
107+
108+
def lite(wp_cli, action):
109+
"""Visual Edit Lite's state before, and switch it (best effort)."""
110+
if not wp_cli:
111+
return None
112+
base = shlex.split(wp_cli)
113+
got = subprocess.run(base + ["plugin", "get", "visual-edit-lite", "--field=status"],
114+
capture_output=True, text=True, timeout=60)
115+
before = got.stdout.strip() if got.returncode == 0 else None
116+
if action and before:
117+
subprocess.run(base + ["plugin", action, "visual-edit-lite"], capture_output=True, text=True, timeout=60)
118+
return before
119+
120+
121+
def main(argv=None):
122+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
123+
ap.add_argument("workspace")
124+
ap.add_argument("--desktop-only", action="store_true")
125+
ap.add_argument("--jobs", type=int, default=2)
126+
ap.add_argument("--wp", default="", help="compare against this address instead of the test-env preview")
127+
args = ap.parse_args(argv)
128+
ws = Path(args.workspace).resolve()
129+
out = ws / "visual-review"
130+
status_path = out / "status.json"
131+
manifest_path = ws / "conversion-manifest.json"
132+
133+
doc = {"schema": SCHEMA + "-status", "state": "running", "startedAt": now(), "updatedAt": now(),
134+
"note": "", "pid": os.getpid()}
135+
try:
136+
old = json.loads(status_path.read_text())
137+
if old.get("state") == "running" and old.get("pid") and old["pid"] != os.getpid():
138+
os.kill(int(old["pid"]), 0)
139+
print(f"visual-compare: a comparison is already running (pid {old['pid']})", file=sys.stderr)
140+
return 1
141+
except (OSError, ValueError, ProcessLookupError):
142+
pass
143+
144+
def status(note, state="running", **extra):
145+
doc.update(state=state, note=note, updatedAt=now(), **extra)
146+
write_json(status_path, doc)
147+
print(f"visual-compare: {note}")
148+
149+
status("starting")
150+
if not manifest_path.is_file():
151+
status("no conversion-manifest.json: nothing converted to compare yet", "failed")
152+
return 1
153+
manifest = json.loads(manifest_path.read_text())
154+
slug = (manifest.get("site") or {}).get("slug") or ""
155+
original = (manifest.get("input") or {}).get("dir") or str(ws / "static-src")
156+
lite_before = None
157+
wp_cli = ""
158+
try:
159+
wp, wp_cli = (args.wp.rstrip("/"), "") if args.wp else preview(ws, slug, status)
160+
lite_before = lite(wp_cli, "deactivate")
161+
index = {"schema": SCHEMA, "capturedAt": None, "preview": wp, "original": original, "pages": {}}
162+
widths = WIDTHS[:1] if args.desktop_only else WIDTHS
163+
for name, width, sub in widths:
164+
status(f"capturing {name} ({width}px)")
165+
target = out / sub if sub else out
166+
run = subprocess.run([sys.executable, "-W", "ignore::SyntaxWarning", str(HERE / "compare-pages.py"),
167+
f"--manifest={manifest_path}", "--wp", wp, "--original", original,
168+
"--out", str(target), "--width", str(width), "--jobs", str(max(1, args.jobs))],
169+
capture_output=True, text=True, timeout=3600)
170+
review = target / "review-manifest.json"
171+
if run.returncode != 0 or not review.is_file():
172+
tail = (run.stdout + run.stderr).strip().splitlines()[-3:]
173+
raise RuntimeError(f"the {name} capture failed: " + " | ".join(tail))
174+
titles = {p.get("file"): p.get("title") for p in manifest.get("pages") or []}
175+
for pair in json.loads(review.read_text()).get("pairs") or []:
176+
key = pair.get("key") or Path(pair.get("page", "")).stem
177+
row = index["pages"].setdefault(key, {"key": key, "page": pair.get("page"),
178+
"title": titles.get(pair.get("page")), "route": pair.get("wpUrl")})
179+
if "composite" in pair:
180+
image = target / pair["composite"]
181+
diff = diff_percent(image, width, (pair.get("origHeight") or 0, pair.get("wpHeight") or 0))
182+
row[name] = {"image": str(image.relative_to(ws)), "diffPercent": diff,
183+
"origHeight": pair.get("origHeight"), "wpHeight": pair.get("wpHeight")}
184+
else:
185+
row[name] = {"error": pair.get("error") or "not captured"}
186+
index["capturedAt"] = now()
187+
index["pages"] = list(index["pages"].values())
188+
write_json(out / "visual-compare.json", index)
189+
made = sum(1 for p in index["pages"] for w in ("desktop", "mobile") if (p.get(w) or {}).get("image"))
190+
status(f"{made} side-by-side composite(s) of {len(index['pages'])} page(s)", "done",
191+
index="visual-review/visual-compare.json")
192+
return 0
193+
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as error:
194+
status(str(error)[:600], "failed")
195+
return 1
196+
finally:
197+
if lite_before == "active":
198+
lite(wp_cli, "activate")
199+
200+
201+
if __name__ == "__main__":
202+
sys.exit(main())

0 commit comments

Comments
 (0)