docs(gallery): refresh canonical 38-type visual catalog (#119)
* docs(gallery): refresh canonical visual catalog * docs: document screenshot renderer setup
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the 38 canonical minimal-light examples and record their digests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from screenshot_catalog import (
|
||||
MANIFEST,
|
||||
ROOT,
|
||||
SCREENSHOT_DIR,
|
||||
canonical_slugs,
|
||||
png_dimensions,
|
||||
screenshot_path,
|
||||
sha256,
|
||||
source_path,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
slugs = canonical_slugs()
|
||||
if len(slugs) != 38 or len(slugs) != len(set(slugs)):
|
||||
raise SystemExit(f"expected 38 unique canonical types; found {len(slugs)}")
|
||||
|
||||
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
entries: list[dict[str, object]] = []
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(
|
||||
viewport={"width": 1440, "height": 1000},
|
||||
device_scale_factor=2,
|
||||
)
|
||||
for index, slug in enumerate(slugs, 1):
|
||||
source = source_path(slug)
|
||||
output = screenshot_path(slug)
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"missing canonical example: {source.relative_to(ROOT)}")
|
||||
|
||||
page.goto(source.resolve().as_uri(), wait_until="networkidle")
|
||||
page.evaluate("() => document.fonts.ready")
|
||||
svg = page.locator("svg").first
|
||||
if svg.count() != 1:
|
||||
raise SystemExit(f"no first SVG found in {source.relative_to(ROOT)}")
|
||||
svg.screenshot(path=str(output), omit_background=True)
|
||||
width, height = png_dimensions(output)
|
||||
entries.append(
|
||||
{
|
||||
"slug": slug,
|
||||
"source": source.relative_to(ROOT).as_posix(),
|
||||
"screenshot": output.relative_to(ROOT).as_posix(),
|
||||
"source_sha256": sha256(source),
|
||||
"screenshot_sha256": sha256(output),
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
)
|
||||
print(f"[{index:02d}/38] {slug}: {width}x{height}")
|
||||
browser.close()
|
||||
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"renderer": {
|
||||
"engine": "playwright-chromium",
|
||||
"scale": 2.0,
|
||||
"viewport": [1440, 1000],
|
||||
"capture": "first-svg",
|
||||
"font_gate": "document.fonts.ready",
|
||||
},
|
||||
"entries": entries,
|
||||
}
|
||||
MANIFEST.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {MANIFEST.relative_to(ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Shared helpers for the canonical README screenshot catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILL = ROOT / "skills/diagram-design/SKILL.md"
|
||||
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||
SCREENSHOT_DIR = ROOT / "docs/screenshots"
|
||||
MANIFEST = SCREENSHOT_DIR / "manifest.json"
|
||||
|
||||
|
||||
def canonical_slugs() -> list[str]:
|
||||
"""Return the visual-type order declared by SKILL.md's selection table."""
|
||||
|
||||
markdown = SKILL.read_text(encoding="utf-8")
|
||||
start = markdown.find("### Visual-type guide")
|
||||
end = markdown.find("Rules of thumb", start)
|
||||
if start < 0 or end < 0:
|
||||
raise ValueError("SKILL.md visual-type guide is missing")
|
||||
return re.findall(
|
||||
r"^\|[^\n]*\]\(references/type-([a-z0-9-]+)\.md\)\s*\|$",
|
||||
markdown[start:end],
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def source_path(slug: str) -> Path:
|
||||
return ASSET_DIR / f"example-{slug}.html"
|
||||
|
||||
|
||||
def screenshot_path(slug: str) -> Path:
|
||||
return SCREENSHOT_DIR / f"{slug}.png"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def png_dimensions(path: Path) -> tuple[int, int]:
|
||||
with path.open("rb") as handle:
|
||||
header = handle.read(24)
|
||||
if len(header) != 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise ValueError(f"not a PNG: {path}")
|
||||
return struct.unpack(">II", header[16:24])
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a canonical example or committed screenshot drifts from its manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from screenshot_catalog import (
|
||||
MANIFEST,
|
||||
ROOT,
|
||||
canonical_slugs,
|
||||
png_dimensions,
|
||||
screenshot_path,
|
||||
sha256,
|
||||
source_path,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
slugs = canonical_slugs()
|
||||
if len(slugs) != 38 or len(slugs) != len(set(slugs)):
|
||||
errors.append(f"expected 38 unique canonical types; found {len(slugs)}")
|
||||
|
||||
if not MANIFEST.is_file():
|
||||
errors.append("docs/screenshots/manifest.json is missing")
|
||||
payload: dict[str, object] = {}
|
||||
else:
|
||||
try:
|
||||
payload = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
errors.append(f"cannot read screenshot manifest: {exc}")
|
||||
payload = {}
|
||||
|
||||
if payload.get("schema_version") != 1:
|
||||
errors.append("screenshot manifest schema_version must be 1")
|
||||
renderer = payload.get("renderer")
|
||||
expected_renderer = {
|
||||
"engine": "playwright-chromium",
|
||||
"scale": 2.0,
|
||||
"viewport": [1440, 1000],
|
||||
"capture": "first-svg",
|
||||
"font_gate": "document.fonts.ready",
|
||||
}
|
||||
if renderer != expected_renderer:
|
||||
errors.append("screenshot manifest must use the canonical 1440x1000, 2x SVG renderer")
|
||||
|
||||
entries = payload.get("entries", [])
|
||||
if not isinstance(entries, list):
|
||||
errors.append("screenshot manifest entries must be a list")
|
||||
entries = []
|
||||
manifest_slugs = [entry.get("slug") for entry in entries if isinstance(entry, dict)]
|
||||
if manifest_slugs != slugs:
|
||||
errors.append("screenshot manifest order does not match SKILL.md's canonical type order")
|
||||
|
||||
by_slug = {
|
||||
entry.get("slug"): entry
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and isinstance(entry.get("slug"), str)
|
||||
}
|
||||
for slug in slugs:
|
||||
source = source_path(slug)
|
||||
screenshot = screenshot_path(slug)
|
||||
entry = by_slug.get(slug)
|
||||
if not source.is_file():
|
||||
errors.append(f"missing canonical example: {source.relative_to(ROOT)}")
|
||||
continue
|
||||
if not screenshot.is_file():
|
||||
errors.append(f"missing canonical screenshot: {screenshot.relative_to(ROOT)}")
|
||||
continue
|
||||
if not entry:
|
||||
errors.append(f"manifest has no entry for {slug}")
|
||||
continue
|
||||
|
||||
expected_source = source.relative_to(ROOT).as_posix()
|
||||
expected_screenshot = screenshot.relative_to(ROOT).as_posix()
|
||||
if entry.get("source") != expected_source:
|
||||
errors.append(f"{slug}: manifest source must be {expected_source}")
|
||||
if entry.get("screenshot") != expected_screenshot:
|
||||
errors.append(f"{slug}: manifest screenshot must be {expected_screenshot}")
|
||||
if entry.get("source_sha256") != sha256(source):
|
||||
errors.append(f"{slug}: source changed; rerun scripts/render-canonical-screenshots.py")
|
||||
if entry.get("screenshot_sha256") != sha256(screenshot):
|
||||
errors.append(f"{slug}: screenshot changed without a matching manifest refresh")
|
||||
try:
|
||||
width, height = png_dimensions(screenshot)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
else:
|
||||
if [entry.get("width"), entry.get("height")] != [width, height]:
|
||||
errors.append(f"{slug}: manifest dimensions do not match the PNG")
|
||||
|
||||
if errors:
|
||||
print("FAIL screenshot freshness")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
return 1
|
||||
print("OK screenshot freshness: 38 canonical sources and PNG digests match")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user