@topy-ai/maggie 0.7.47 → 0.7.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +76 -15
- package/bin/maggie.js +23 -6
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +141 -7
- package/bundled-skills/maggie-deployment/references/vps.md +74 -1
- package/bundled-skills/maggie-design/SKILL.md +154 -0
- package/bundled-skills/maggie-feedback/SKILL.md +67 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
- package/bundled-skills/maggie-social-share/SKILL.md +56 -7
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +307 -18
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +17 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +115 -5
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +442 -0
- package/bundled-tools/clis/maggie_social_share.py +46 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/google-integrations-runbook.md +54 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Package-quality checks, design measurement, studio preview, and image review
|
|
3
|
+
for Maggie marketplace templates.
|
|
4
|
+
|
|
5
|
+
The functions here are read-only unless a caller passes ``write=True``. They
|
|
6
|
+
make no network or model calls; optional local tools (Pillow, tesseract,
|
|
7
|
+
ImageMagick, google-chrome) are used only when installed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import colorsys
|
|
13
|
+
import html as html_lib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import struct
|
|
18
|
+
import subprocess
|
|
19
|
+
import threading
|
|
20
|
+
import zlib
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from functools import partial
|
|
23
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# sharp's documented default `limitInputPixels` is 268402689 (0x3FFF * 0x3FFF).
|
|
27
|
+
# Package screenshots stay far below it so standard tooling (sharp, Pillow's
|
|
28
|
+
# decompression-bomb guard at ~89 MP, WebP's 16383 px edge) never needs limits
|
|
29
|
+
# disabled. Full-page captures are tiled instead (see the marketplace skill).
|
|
30
|
+
SHARP_DEFAULT_LIMIT_INPUT_PIXELS = 268_402_689
|
|
31
|
+
SCREENSHOT_PIXEL_BUDGET = 40_000_000
|
|
32
|
+
SCREENSHOT_MAX_EDGE = 16_383
|
|
33
|
+
SCREENSHOT_TILE_HEIGHT = 4_096
|
|
34
|
+
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".svg"}
|
|
35
|
+
CAPTURED_PROVIDER = "captured-source"
|
|
36
|
+
UNRECORDED_PROVIDER = "unrecorded"
|
|
37
|
+
NON_GENERATED_PROVIDERS = {CAPTURED_PROVIDER, UNRECORDED_PROVIDER, "pending", "source-derived-local", "", None}
|
|
38
|
+
STUDIO_VIEWPORTS = (390, 768, 1024, 1440)
|
|
39
|
+
PALETTE_ROLES = ("canvas", "panel", "soft", "text", "accent", "accentText", "onAccent", "dark", "deep")
|
|
40
|
+
HEX_RE = re.compile(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})(?![0-9a-fA-F])")
|
|
41
|
+
SERIF_HINTS = ("serif", "garamond", "playfair", "cormorant", "merriweather", "lora", "fraunces", "baskerville", "bodoni", "didot", "caslon", "instrument serif", "dm serif", "libre", "crimson", "prata", "marcellus", "cinzel", "italiana", "gloock")
|
|
42
|
+
GENERIC_NAME_WORDS = {
|
|
43
|
+
"spa", "spas", "wellness", "beauty", "studio", "salon", "massage", "therapy", "clinic", "medical", "medspa", "landing",
|
|
44
|
+
"page", "template", "premium", "luxury", "editorial", "the", "and", "of", "for", "in", "a", "an", "your", "with", "by",
|
|
45
|
+
"health", "care", "body", "skin", "hair", "head", "foot", "float", "booking", "book", "resort", "hotel", "recovery",
|
|
46
|
+
"inner", "radiance", "discover", "considered", "london", "uk", "thai", "korean", "traditional", "traditionelle",
|
|
47
|
+
"thai-massage", "wellbeing", "aesthetics", "treatments", "home", "official", "site", "maggie", "restoration", "repair",
|
|
48
|
+
"service", "card", "cards", "damaged", "fix", "premier", "&", "-", "|",
|
|
49
|
+
}
|
|
50
|
+
HUE_WORDS = (
|
|
51
|
+
(0, "Rose"), (15, "Coral"), (25, "Terracotta"), (38, "Amber"), (50, "Ochre"), (68, "Olive"), (95, "Moss"),
|
|
52
|
+
(135, "Sage"), (160, "Jade"), (180, "Teal"), (200, "Ocean"), (222, "Cobalt"), (242, "Indigo"), (262, "Iris"),
|
|
53
|
+
(280, "Violet"), (305, "Plum"), (325, "Orchid"), (345, "Rose"),
|
|
54
|
+
)
|
|
55
|
+
# Tailwind v3 default neutral scales, so `bg-stone-50`/`text-zinc-900` resolve.
|
|
56
|
+
_TW_STEPS = ("50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950")
|
|
57
|
+
_TW_NEUTRALS = {
|
|
58
|
+
"slate": "f8fafc f1f5f9 e2e8f0 cbd5e1 94a3b8 64748b 475569 334155 1e293b 0f172a 020617",
|
|
59
|
+
"gray": "f9fafb f3f4f6 e5e7eb d1d5db 9ca3af 6b7280 4b5563 374151 1f2937 111827 030712",
|
|
60
|
+
"zinc": "fafafa f4f4f5 e4e4e7 d4d4d8 a1a1aa 71717a 52525b 3f3f46 27272a 18181b 09090b",
|
|
61
|
+
"neutral": "fafafa f5f5f5 e5e5e5 d4d4d4 a3a3a3 737373 525252 404040 262626 171717 0a0a0a",
|
|
62
|
+
"stone": "fafaf9 f5f5f4 e7e5e4 d6d3d1 a8a29e 78716c 57534e 44403c 292524 1c1917 0c0a09",
|
|
63
|
+
}
|
|
64
|
+
TAILWIND_NEUTRALS = {f"{name}-{step}": "#" + value.upper() for name, values in _TW_NEUTRALS.items() for step, value in zip(_TW_STEPS, values.split())}
|
|
65
|
+
CONTENT_SCHEMA_VERSION = "maggie-template-content-v1"
|
|
66
|
+
REPORT_SCHEMA_VERSION = "maggie-image-generation-report-v1"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def now() -> str:
|
|
70
|
+
return datetime.now(timezone.utc).isoformat()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_json(path: Path) -> dict:
|
|
74
|
+
try:
|
|
75
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
except json.JSONDecodeError as error:
|
|
77
|
+
raise ValueError(f"invalid JSON in {path.name}: {error}") from error
|
|
78
|
+
if not isinstance(value, dict):
|
|
79
|
+
raise ValueError(f"{path.name} must contain a JSON object")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write_json(path: Path, value: dict) -> None:
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Image decoding
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _png_info(data: bytes) -> dict:
|
|
93
|
+
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
94
|
+
return {"format": "png", "decodable": False, "error": "missing PNG signature"}
|
|
95
|
+
offset, idat, header = 8, bytearray(), None
|
|
96
|
+
while offset + 12 <= len(data):
|
|
97
|
+
length, kind = struct.unpack(">I4s", data[offset:offset + 8])
|
|
98
|
+
end = offset + 12 + length
|
|
99
|
+
if end > len(data):
|
|
100
|
+
return {"format": "png", "decodable": False, "error": f"truncated {kind.decode('latin-1')} chunk"}
|
|
101
|
+
body = data[offset + 8:offset + 8 + length]
|
|
102
|
+
(crc,) = struct.unpack(">I", data[offset + 8 + length:end])
|
|
103
|
+
if zlib.crc32(kind + body) & 0xFFFFFFFF != crc:
|
|
104
|
+
return {"format": "png", "decodable": False, "error": f"{kind.decode('latin-1')} chunk CRC mismatch (corrupt image data)", **(header or {})}
|
|
105
|
+
if kind == b"IHDR":
|
|
106
|
+
width, height, depth, colour, _, _, interlace = struct.unpack(">IIBBBBB", body)
|
|
107
|
+
header = {"width": width, "height": height, "depth": depth, "colourType": colour, "interlace": interlace}
|
|
108
|
+
elif kind == b"IDAT":
|
|
109
|
+
idat.extend(body)
|
|
110
|
+
elif kind == b"IEND":
|
|
111
|
+
break
|
|
112
|
+
offset = end
|
|
113
|
+
if not header:
|
|
114
|
+
return {"format": "png", "decodable": False, "error": "missing IHDR chunk"}
|
|
115
|
+
try:
|
|
116
|
+
raw = zlib.decompress(bytes(idat))
|
|
117
|
+
except zlib.error as error:
|
|
118
|
+
return {"format": "png", "decodable": False, "error": f"image data does not inflate: {error}", **header}
|
|
119
|
+
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(header["colourType"], 0)
|
|
120
|
+
if channels and header["interlace"] == 0:
|
|
121
|
+
stride = (header["width"] * channels * header["depth"] + 7) // 8 + 1
|
|
122
|
+
if len(raw) != stride * header["height"]:
|
|
123
|
+
return {"format": "png", "decodable": False, "error": "decoded size does not match the PNG header", **header}
|
|
124
|
+
return {"format": "png", "decodable": True, **header}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _jpeg_info(data: bytes) -> dict:
|
|
128
|
+
if data[:2] != b"\xff\xd8":
|
|
129
|
+
return {"format": "jpeg", "decodable": False, "error": "missing JPEG start marker"}
|
|
130
|
+
offset, size = 2, None
|
|
131
|
+
while offset + 4 <= len(data):
|
|
132
|
+
if data[offset] != 0xFF:
|
|
133
|
+
offset += 1
|
|
134
|
+
continue
|
|
135
|
+
marker = data[offset + 1]
|
|
136
|
+
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
|
137
|
+
offset += 2
|
|
138
|
+
continue
|
|
139
|
+
(length,) = struct.unpack(">H", data[offset + 2:offset + 4])
|
|
140
|
+
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
|
141
|
+
height, width = struct.unpack(">HH", data[offset + 5:offset + 9])
|
|
142
|
+
size = {"width": width, "height": height}
|
|
143
|
+
break
|
|
144
|
+
offset += 2 + length
|
|
145
|
+
if not size:
|
|
146
|
+
return {"format": "jpeg", "decodable": False, "error": "missing JPEG frame header"}
|
|
147
|
+
if b"\xff\xd9" not in data[-4096:]:
|
|
148
|
+
return {"format": "jpeg", "decodable": False, "error": "missing JPEG end marker (truncated)", **size}
|
|
149
|
+
return {"format": "jpeg", "decodable": True, **size}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _webp_info(data: bytes) -> dict:
|
|
153
|
+
if data[:4] != b"RIFF" or data[8:12] != b"WEBP":
|
|
154
|
+
return {"format": "webp", "decodable": False, "error": "missing WebP header"}
|
|
155
|
+
(riff_size,) = struct.unpack("<I", data[4:8])
|
|
156
|
+
if riff_size + 8 > len(data):
|
|
157
|
+
return {"format": "webp", "decodable": False, "error": "truncated WebP container"}
|
|
158
|
+
chunk = data[12:16]
|
|
159
|
+
if chunk == b"VP8X":
|
|
160
|
+
width = 1 + int.from_bytes(data[24:27], "little")
|
|
161
|
+
height = 1 + int.from_bytes(data[27:30], "little")
|
|
162
|
+
elif chunk == b"VP8L":
|
|
163
|
+
bits = int.from_bytes(data[21:25], "little")
|
|
164
|
+
width, height = (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
165
|
+
elif chunk == b"VP8 ":
|
|
166
|
+
width, height = (struct.unpack("<H", data[26:28])[0] & 0x3FFF), (struct.unpack("<H", data[28:30])[0] & 0x3FFF)
|
|
167
|
+
else:
|
|
168
|
+
return {"format": "webp", "decodable": False, "error": "unknown WebP chunk"}
|
|
169
|
+
return {"format": "webp", "decodable": True, "width": width, "height": height}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def sniff_image_format(data: bytes) -> str | None:
|
|
173
|
+
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
174
|
+
return "png"
|
|
175
|
+
if data[:2] == b"\xff\xd8":
|
|
176
|
+
return "jpeg"
|
|
177
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
178
|
+
return "webp"
|
|
179
|
+
if data[:6] in (b"GIF87a", b"GIF89a"):
|
|
180
|
+
return "gif"
|
|
181
|
+
if data[4:12] in (b"ftypavif", b"ftypavis"):
|
|
182
|
+
return "avif"
|
|
183
|
+
head = data[:512].lstrip().lower()
|
|
184
|
+
if head.startswith(b"<svg") or (head.startswith(b"<?xml") and b"<svg" in data[:2048].lower()):
|
|
185
|
+
return "svg"
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def inspect_image(path: Path) -> dict:
|
|
190
|
+
"""Return format, dimensions, and whether the image fully decodes."""
|
|
191
|
+
data = path.read_bytes()
|
|
192
|
+
kind = sniff_image_format(data)
|
|
193
|
+
if kind == "png":
|
|
194
|
+
info = _png_info(data)
|
|
195
|
+
elif kind == "jpeg":
|
|
196
|
+
info = _jpeg_info(data)
|
|
197
|
+
elif kind == "webp":
|
|
198
|
+
info = _webp_info(data)
|
|
199
|
+
elif kind in {"gif", "avif", "svg"}:
|
|
200
|
+
info = {"format": kind, "decodable": True}
|
|
201
|
+
else:
|
|
202
|
+
return {"format": None, "decodable": False, "error": "unrecognised image format"}
|
|
203
|
+
if info.get("decodable") and kind in {"jpeg", "webp", "gif"}:
|
|
204
|
+
try: # Pillow, when installed, catches corrupt entropy-coded data too.
|
|
205
|
+
from PIL import Image # type: ignore
|
|
206
|
+
|
|
207
|
+
with Image.open(path) as image:
|
|
208
|
+
image.load()
|
|
209
|
+
except ImportError:
|
|
210
|
+
pass
|
|
211
|
+
except Exception as error: # noqa: BLE001 - any decoder failure is the finding
|
|
212
|
+
info.update({"decodable": False, "error": f"decoder failed: {error}"})
|
|
213
|
+
return info
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# T13a: screenshot integrity and pixel budget
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def screenshot_report(path: Path) -> dict:
|
|
221
|
+
files = {}
|
|
222
|
+
for item in sorted((path / "screenshots").glob("*")) if (path / "screenshots").is_dir() else []:
|
|
223
|
+
if not item.is_file() or item.suffix.lower() not in IMAGE_SUFFIXES:
|
|
224
|
+
continue
|
|
225
|
+
info = inspect_image(item)
|
|
226
|
+
entry = {key: info[key] for key in ("format", "width", "height", "decodable", "error") if key in info}
|
|
227
|
+
width, height = info.get("width") or 0, info.get("height") or 0
|
|
228
|
+
if width * height > SCREENSHOT_PIXEL_BUDGET or max(width, height) > SCREENSHOT_MAX_EDGE:
|
|
229
|
+
entry["overBudget"] = True
|
|
230
|
+
files[item.name] = entry
|
|
231
|
+
failing = [name for name, entry in files.items() if not entry.get("decodable") or entry.get("overBudget")]
|
|
232
|
+
return {
|
|
233
|
+
"status": "recapture-required" if failing else "passed",
|
|
234
|
+
"checkedAt": now(),
|
|
235
|
+
"pixelBudget": SCREENSHOT_PIXEL_BUDGET,
|
|
236
|
+
"maxEdge": SCREENSHOT_MAX_EDGE,
|
|
237
|
+
"files": files,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def audit_screenshots(path: Path) -> list[str]:
|
|
242
|
+
errors = []
|
|
243
|
+
report = screenshot_report(path)
|
|
244
|
+
for name, entry in report["files"].items():
|
|
245
|
+
if not entry.get("decodable"):
|
|
246
|
+
errors.append(f"screenshot is not decodable and must be recaptured: screenshots/{name} ({entry.get('error', 'unknown error')})")
|
|
247
|
+
if entry.get("overBudget"):
|
|
248
|
+
errors.append(
|
|
249
|
+
f"screenshot exceeds the {SCREENSHOT_PIXEL_BUDGET // 1_000_000} MP / {SCREENSHOT_MAX_EDGE}px budget: screenshots/{name} "
|
|
250
|
+
f"({entry.get('width')}x{entry.get('height')}); tile full-page captures into <= {SCREENSHOT_TILE_HEIGHT}px segments or ship a compressed WebP preview"
|
|
251
|
+
)
|
|
252
|
+
metadata_path = path / "template.json"
|
|
253
|
+
if metadata_path.exists():
|
|
254
|
+
try:
|
|
255
|
+
review = _read_json(metadata_path).get("screenshotReview")
|
|
256
|
+
except ValueError:
|
|
257
|
+
review = None
|
|
258
|
+
if isinstance(review, dict) and review.get("status") == "recapture-required" and not errors:
|
|
259
|
+
errors.append("template.json screenshotReview is recapture-required; recapture and rerun audit-package --record")
|
|
260
|
+
return errors
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# T13c: asset provenance
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _asset_files(path: Path) -> list[Path]:
|
|
268
|
+
root = path / "assets"
|
|
269
|
+
if not root.is_dir():
|
|
270
|
+
return []
|
|
271
|
+
files = []
|
|
272
|
+
for item in sorted(root.rglob("*")):
|
|
273
|
+
if not item.is_file():
|
|
274
|
+
continue
|
|
275
|
+
relative = item.relative_to(path)
|
|
276
|
+
if "phosphor" in relative.parts or item.suffix.lower() in {".css", ".woff", ".woff2", ".ttf", ".otf", ".js", ".map"}:
|
|
277
|
+
continue # vendored runtime files are covered by the stylesheet/icon gates
|
|
278
|
+
if item.suffix.lower() in IMAGE_SUFFIXES or not item.suffix:
|
|
279
|
+
files.append(item)
|
|
280
|
+
return files
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def manifest_provider(manifest_path: Path) -> str | None:
|
|
284
|
+
"""Top-level provider applies to entries without one, except the pipeline placeholder."""
|
|
285
|
+
if not manifest_path.exists():
|
|
286
|
+
return None
|
|
287
|
+
provider = _read_json(manifest_path).get("provider")
|
|
288
|
+
return None if provider in {None, "", "maggie-marketplace-pipeline"} else str(provider)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def audit_asset_provenance(path: Path, published: bool = False) -> list[str]:
|
|
292
|
+
errors = []
|
|
293
|
+
manifest_path = path / "generated-asset-manifest.json"
|
|
294
|
+
try:
|
|
295
|
+
entries = _read_json(manifest_path).get("assets", []) if manifest_path.exists() else []
|
|
296
|
+
except ValueError as error:
|
|
297
|
+
return [f"invalid generated asset manifest: {error}"]
|
|
298
|
+
listed = {str(item.get("path")): item for item in entries if isinstance(item, dict) and item.get("path")}
|
|
299
|
+
inherited = manifest_provider(manifest_path)
|
|
300
|
+
for item in _asset_files(path):
|
|
301
|
+
relative = item.relative_to(path).as_posix()
|
|
302
|
+
if not item.suffix:
|
|
303
|
+
errors.append(f"asset has no file extension: {relative}")
|
|
304
|
+
entry = listed.get(relative)
|
|
305
|
+
if entry is None:
|
|
306
|
+
errors.append(f"asset is not listed in generated-asset-manifest.json: {relative}")
|
|
307
|
+
continue
|
|
308
|
+
provider = entry.get("provider") or inherited
|
|
309
|
+
if not provider or not entry.get("role"):
|
|
310
|
+
errors.append(f"asset manifest entry needs provider and role: {relative}")
|
|
311
|
+
if provider in {CAPTURED_PROVIDER, UNRECORDED_PROVIDER} and entry.get("reuse") is not False:
|
|
312
|
+
errors.append(f"{provider} asset must be marked reuse=false: {relative}")
|
|
313
|
+
if published and provider == CAPTURED_PROVIDER:
|
|
314
|
+
errors.append(f"captured third-party asset must be replaced before publishing: {relative}")
|
|
315
|
+
if published and provider == UNRECORDED_PROVIDER:
|
|
316
|
+
errors.append(f"asset provenance must be confirmed before publishing: {relative}")
|
|
317
|
+
return errors
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def repair_asset_provenance(path: Path, write: bool = False) -> dict:
|
|
321
|
+
"""List every unlisted asset honestly and give extensionless files a suffix."""
|
|
322
|
+
manifest_path = path / "generated-asset-manifest.json"
|
|
323
|
+
manifest = _read_json(manifest_path) if manifest_path.exists() else {"provider": "maggie-marketplace-pipeline", "assets": []}
|
|
324
|
+
entries = manifest.setdefault("assets", [])
|
|
325
|
+
source_items = {}
|
|
326
|
+
source_manifest = path / "source" / "asset-manifest.json"
|
|
327
|
+
if source_manifest.exists():
|
|
328
|
+
for item in _read_json(source_manifest).get("items", []):
|
|
329
|
+
name = Path(str(item.get("local_path") or item.get("project_path") or "")).name
|
|
330
|
+
if name:
|
|
331
|
+
source_items[name] = item
|
|
332
|
+
alts = {}
|
|
333
|
+
inventory = path / "image-inventory.json"
|
|
334
|
+
if inventory.exists():
|
|
335
|
+
for item in _read_json(inventory).get("occurrences", []):
|
|
336
|
+
if item.get("path"):
|
|
337
|
+
alts.setdefault(item["path"], item.get("alt") or "")
|
|
338
|
+
renames, added = [], []
|
|
339
|
+
listed = {item.get("path") for item in entries if isinstance(item, dict)}
|
|
340
|
+
for item in _asset_files(path):
|
|
341
|
+
relative = item.relative_to(path).as_posix()
|
|
342
|
+
source = source_items.get(item.name)
|
|
343
|
+
target = item
|
|
344
|
+
if not item.suffix:
|
|
345
|
+
kind = sniff_image_format(item.read_bytes()[:4096])
|
|
346
|
+
suffix = {"jpeg": ".jpg"}.get(kind or "", f".{kind}" if kind else "")
|
|
347
|
+
if suffix:
|
|
348
|
+
target = item.with_name(item.name + suffix)
|
|
349
|
+
renames.append((relative, target.relative_to(path).as_posix()))
|
|
350
|
+
new_relative = target.relative_to(path).as_posix()
|
|
351
|
+
for entry in entries:
|
|
352
|
+
if entry.get("path") == relative and new_relative != relative:
|
|
353
|
+
entry["path"] = new_relative
|
|
354
|
+
if relative in listed:
|
|
355
|
+
continue
|
|
356
|
+
if source is not None:
|
|
357
|
+
host = re.sub(r"^https?://([^/]+).*$", r"\1", str(source.get("url") or ""))
|
|
358
|
+
entry = {"path": new_relative, "role": "captured source image", "provider": CAPTURED_PROVIDER,
|
|
359
|
+
"sourceHost": host or None, "reuse": False, "replacementRequired": True,
|
|
360
|
+
"note": "Captured from the source page as clone evidence; replace with a generated or licensed asset before publishing."}
|
|
361
|
+
else:
|
|
362
|
+
entry = {"path": new_relative, "role": alts.get(relative) or "content image", "provider": UNRECORDED_PROVIDER,
|
|
363
|
+
"reuse": False, "provenanceReview": "required",
|
|
364
|
+
"note": "Present in assets/ without a generation record; confirm provenance before reuse."}
|
|
365
|
+
entries.append(entry)
|
|
366
|
+
added.append(entry)
|
|
367
|
+
if write:
|
|
368
|
+
for old, new in renames:
|
|
369
|
+
(path / old).rename(path / new)
|
|
370
|
+
if renames:
|
|
371
|
+
for name in ("homepage.html", "image-inventory.json", "README.md"):
|
|
372
|
+
document = path / name
|
|
373
|
+
if not document.exists():
|
|
374
|
+
continue
|
|
375
|
+
text = document.read_text(encoding="utf-8")
|
|
376
|
+
for old, new in renames:
|
|
377
|
+
text = re.sub(re.escape(old) + r"(?![\w.])", new, text)
|
|
378
|
+
document.write_text(text, encoding="utf-8")
|
|
379
|
+
_write_json(manifest_path, manifest)
|
|
380
|
+
return {"renamed": renames, "added": added}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
# T13b: measured design tokens
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def norm_hex(value: str) -> str:
|
|
388
|
+
value = value.strip().lstrip("#")
|
|
389
|
+
if len(value) == 3:
|
|
390
|
+
value = "".join(ch * 2 for ch in value)
|
|
391
|
+
return "#" + value.upper()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _rgb(value: str) -> tuple[int, int, int]:
|
|
395
|
+
value = norm_hex(value)
|
|
396
|
+
return int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def luminance(value: str) -> float:
|
|
400
|
+
def channel(c: int) -> float:
|
|
401
|
+
c = c / 255
|
|
402
|
+
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
403
|
+
|
|
404
|
+
r, g, b = _rgb(value)
|
|
405
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def contrast(a: str, b: str) -> float:
|
|
409
|
+
high, low = sorted((luminance(a), luminance(b)), reverse=True)
|
|
410
|
+
return (high + 0.05) / (low + 0.05)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def saturation(value: str) -> float:
|
|
414
|
+
r, g, b = (c / 255 for c in _rgb(value))
|
|
415
|
+
_, light, sat = colorsys.rgb_to_hls(r, g, b)
|
|
416
|
+
return sat if 0.06 < light < 0.94 else 0.0
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def chroma(value: str) -> int:
|
|
420
|
+
channels = _rgb(value)
|
|
421
|
+
return max(channels) - min(channels)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def colour_distance(a: str, b: str) -> float:
|
|
425
|
+
return sum((x - y) ** 2 for x, y in zip(_rgb(a), _rgb(b))) ** 0.5
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _darken_until(colour: str, against: str, target: float = 4.5) -> str:
|
|
429
|
+
r, g, b = (c / 255 for c in _rgb(colour))
|
|
430
|
+
hue, light, sat = colorsys.rgb_to_hls(r, g, b)
|
|
431
|
+
lighter = luminance(against) < 0.18
|
|
432
|
+
for _ in range(100):
|
|
433
|
+
candidate = "#%02X%02X%02X" % tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, light, sat))
|
|
434
|
+
if contrast(candidate, against) >= target:
|
|
435
|
+
return candidate
|
|
436
|
+
light = min(1.0, light + 0.01) if lighter else max(0.0, light - 0.01)
|
|
437
|
+
return "#FFFFFF" if lighter else "#000000"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _tailwind_config(markup: str) -> str:
|
|
441
|
+
match = re.search(r"tailwind\.config\s*=\s*(\{.*?\})\s*;?\s*</script>", markup, re.S)
|
|
442
|
+
return match.group(1) if match else ""
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _config_colours(config: str) -> dict[str, str]:
|
|
446
|
+
block = re.search(r"colors\s*:\s*\{(.*?)\}\s*,?\s*(?:fontFamily|backgroundImage|spacing|extend|animation|keyframes|boxShadow|\}\s*\})", config, re.S)
|
|
447
|
+
colours = {}
|
|
448
|
+
text = block.group(1) if block else ""
|
|
449
|
+
parent = None
|
|
450
|
+
for match in re.finditer(r"['\"]?([\w-]+)['\"]?\s*:\s*(?:(\{)|['\"](#[0-9a-fA-F]{3,6})['\"])|(\})", text):
|
|
451
|
+
if match.group(4):
|
|
452
|
+
parent = None
|
|
453
|
+
continue
|
|
454
|
+
key, value = match.group(1), match.group(3)
|
|
455
|
+
if match.group(2):
|
|
456
|
+
parent = key
|
|
457
|
+
continue
|
|
458
|
+
name = key
|
|
459
|
+
if parent and key == "DEFAULT":
|
|
460
|
+
name = parent
|
|
461
|
+
elif parent:
|
|
462
|
+
name = f"{parent}-{key}"
|
|
463
|
+
colours[name] = norm_hex(value)
|
|
464
|
+
return colours
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _config_fonts(config: str) -> dict[str, str]:
|
|
468
|
+
fonts = {}
|
|
469
|
+
block = re.search(r"fontFamily\s*:\s*\{(.*?)\}", config, re.S)
|
|
470
|
+
for match in re.finditer(r"['\"]?([\w-]+)['\"]?\s*:\s*\[\s*['\"]+([^'\"]+)", block.group(1) if block else ""):
|
|
471
|
+
fonts[match.group(1)] = match.group(2).strip()
|
|
472
|
+
return fonts
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def measure_design_tokens(markup: str) -> dict:
|
|
476
|
+
"""Measure palette roles and fonts from a template's homepage CSS."""
|
|
477
|
+
config = _tailwind_config(markup)
|
|
478
|
+
config_colours = _config_colours(config)
|
|
479
|
+
named = {"white": "#FFFFFF", "black": "#000000", **TAILWIND_NEUTRALS, **config_colours}
|
|
480
|
+
usage: dict[str, dict[str, int]] = {"bg": {}, "text": {}, "border": {}, "any": {}}
|
|
481
|
+
|
|
482
|
+
def count(kind: str, colour: str, weight: int = 1) -> None:
|
|
483
|
+
colour = norm_hex(colour)
|
|
484
|
+
usage[kind][colour] = usage[kind].get(colour, 0) + weight
|
|
485
|
+
usage["any"][colour] = usage["any"].get(colour, 0) + weight
|
|
486
|
+
|
|
487
|
+
for classes in re.findall(r"\bclass=[\"']([^\"']*)[\"']", markup):
|
|
488
|
+
for token in classes.split():
|
|
489
|
+
base = token.split(":")[-1]
|
|
490
|
+
match = re.fullmatch(r"(bg|text|border|from|via|to|fill|stroke|ring|divide|decoration)-(?:\[(#[0-9a-fA-F]{3,6})\]|([a-z][\w-]*?))(?:/\d+)?", base)
|
|
491
|
+
if not match:
|
|
492
|
+
continue
|
|
493
|
+
colour = match.group(2) or named.get(match.group(3) or "")
|
|
494
|
+
if not colour:
|
|
495
|
+
continue
|
|
496
|
+
kind = {"bg": "bg", "from": "bg", "via": "bg", "to": "bg", "text": "text", "fill": "text", "decoration": "text"}.get(match.group(1), "border")
|
|
497
|
+
count(kind, colour)
|
|
498
|
+
styles = " ".join(re.findall(r"<style[^>]*>(.*?)</style>", markup, re.S | re.I)) + " " + " ".join(re.findall(r"\bstyle=[\"']([^\"']*)", markup))
|
|
499
|
+
for prop, colour in re.findall(r"(background(?:-color)?|color|border(?:-[a-z]+)?(?:-color)?)\s*:\s*[^;{}]*?(#[0-9a-fA-F]{3,6})\b", styles):
|
|
500
|
+
count("bg" if prop.startswith("background") else "text" if prop == "color" else "border", colour)
|
|
501
|
+
body_bg = body_text = None
|
|
502
|
+
for rule in re.findall(r"(?:^|[}\s,])body\s*\{([^}]*)\}", styles):
|
|
503
|
+
bg = re.search(r"background(?:-color)?\s*:\s*(#[0-9a-fA-F]{3,6})", rule)
|
|
504
|
+
fg = re.search(r"(?<![-\w])color\s*:\s*(#[0-9a-fA-F]{3,6})", rule)
|
|
505
|
+
body_bg = norm_hex(bg.group(1)) if bg else body_bg
|
|
506
|
+
body_text = norm_hex(fg.group(1)) if fg else body_text
|
|
507
|
+
body = re.search(r"<body\b[^>]*\bclass=[\"']([^\"']*)", markup, re.I)
|
|
508
|
+
for token in (body.group(1).split() if body else []):
|
|
509
|
+
match = re.fullmatch(r"(bg|text)-(?:\[(#[0-9a-fA-F]{3,6})\]|([a-z][\w-]*))", token)
|
|
510
|
+
if match:
|
|
511
|
+
colour = match.group(2) or named.get(match.group(3) or "")
|
|
512
|
+
if colour and match.group(1) == "bg":
|
|
513
|
+
body_bg = norm_hex(colour)
|
|
514
|
+
elif colour:
|
|
515
|
+
body_text = norm_hex(colour)
|
|
516
|
+
|
|
517
|
+
def ranked(kind: str) -> list[str]:
|
|
518
|
+
return [colour for colour, _ in sorted(usage[kind].items(), key=lambda item: (-item[1], item[0]))]
|
|
519
|
+
|
|
520
|
+
backgrounds = ranked("bg")
|
|
521
|
+
canvas = body_bg or next((c for c in backgrounds if luminance(c) > 0.6), None) or (backgrounds[0] if backgrounds else "#FFFFFF")
|
|
522
|
+
texts = ranked("text")
|
|
523
|
+
text = body_text or next((c for c in texts if contrast(c, canvas) >= 4.5), None) or ("#111111" if luminance(canvas) > 0.4 else "#FFFFFF")
|
|
524
|
+
# Brand accents are often muted (olive, sage, clay), so rank by chroma
|
|
525
|
+
# rather than HLS saturation, and prefer colours the template named.
|
|
526
|
+
tokens = set(config_colours.values())
|
|
527
|
+
accents = [c for c in ranked("any") if chroma(c) >= 14 and c not in {canvas, text} and 0.02 < luminance(c) < 0.85]
|
|
528
|
+
accent = next((c for c in accents if c in tokens), None) or (accents[0] if accents else text)
|
|
529
|
+
light_bgs = [c for c in backgrounds if c != canvas and abs(luminance(c) - luminance(canvas)) < 0.35]
|
|
530
|
+
panel = light_bgs[0] if light_bgs else canvas
|
|
531
|
+
soft = next((c for c in light_bgs[1:] if c != panel), panel)
|
|
532
|
+
darks = [c for c in backgrounds if luminance(c) < 0.2]
|
|
533
|
+
dark = darks[0] if darks else text
|
|
534
|
+
every = list(usage["any"]) or [text]
|
|
535
|
+
deep = min(every + [dark], key=luminance)
|
|
536
|
+
muted = next((c for c in texts if c not in {text, "#FFFFFF"} and contrast(c, canvas) >= 3), None)
|
|
537
|
+
border = (ranked("border") or [soft])[0]
|
|
538
|
+
roles = {
|
|
539
|
+
"canvas": canvas, "panel": panel, "soft": soft, "text": text, "accent": accent,
|
|
540
|
+
"accentText": accent if contrast(accent, canvas) >= 4.5 else _darken_until(accent, canvas),
|
|
541
|
+
"onAccent": max(("#FFFFFF", text), key=lambda c: contrast(c, accent)),
|
|
542
|
+
"dark": dark, "deep": deep,
|
|
543
|
+
}
|
|
544
|
+
fonts = _config_fonts(config)
|
|
545
|
+
families = []
|
|
546
|
+
for href in re.findall(r"<link\b(?![^>]*all-fonts-link)[^>]*href=[\"']([^\"']*fonts\.googleapis\.com/css2?[^\"']*)", markup, re.I):
|
|
547
|
+
for family in re.findall(r"family=([^:&;]+)", html_lib.unescape(href)):
|
|
548
|
+
families.append(family.replace("+", " "))
|
|
549
|
+
body_font = None
|
|
550
|
+
for rule in re.findall(r"(?:^|[}\s,])body\s*\{([^}]*)\}", styles):
|
|
551
|
+
match = re.search(r"font-family\s*:\s*['\"]?([^,'\";]+)", rule)
|
|
552
|
+
body_font = match.group(1).strip() if match and "var(" not in match.group(1) else body_font
|
|
553
|
+
display = fonts.get("serif") or fonts.get("display") or fonts.get("heading") or (families[0] if families else None) or body_font
|
|
554
|
+
sans = fonts.get("sans") or body_font or (families[-1] if families else None)
|
|
555
|
+
loaded = set(families)
|
|
556
|
+
missing_links = sorted({name for name in (display, sans) if name and name not in loaded and name.lower() not in {"serif", "sans-serif", "system-ui"}})
|
|
557
|
+
return {
|
|
558
|
+
"roles": roles,
|
|
559
|
+
"textSecondary": muted,
|
|
560
|
+
"border": border,
|
|
561
|
+
"fonts": {"display": display, "body": sans, "loadedFamilies": families, "missingFontLinks": missing_links},
|
|
562
|
+
"usedColours": sorted(set(usage["any"]) | set(named.values()) | {canvas, text}),
|
|
563
|
+
"measuredFrom": "homepage.html",
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def parse_design_palette(design: str) -> dict[str, str]:
|
|
568
|
+
section = re.search(r"^#{2,4}[^\n]*palette[^\n]*\n(.*?)(?=\n#{1,4}\s|\Z)", design, re.S | re.I | re.M)
|
|
569
|
+
# Fall back to the whole document for packages whose palette is a table
|
|
570
|
+
# under an "approved palette" paragraph rather than a palette heading.
|
|
571
|
+
body = section.group(1) if section else design
|
|
572
|
+
palette = {}
|
|
573
|
+
for label, colour in re.findall(r"^-\s*([^:\n]+):\s*`?(#[0-9a-fA-F]{3,6})`?", body, re.M):
|
|
574
|
+
palette[label.strip()] = norm_hex(colour)
|
|
575
|
+
for label, colour in re.findall(r"^\|\s*([^|\n]+?)\s*\|\s*`?(#[0-9a-fA-F]{3,6})`?", body, re.M):
|
|
576
|
+
palette.setdefault(label.strip(), norm_hex(colour))
|
|
577
|
+
return palette
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def audit_design_palette(path: Path, tolerance: float = 18.0) -> list[str]:
|
|
581
|
+
design_path, homepage = path / "DESIGN.md", path / "homepage.html"
|
|
582
|
+
if not design_path.exists() or not homepage.exists():
|
|
583
|
+
return []
|
|
584
|
+
declared = parse_design_palette(design_path.read_text(encoding="utf-8", errors="replace"))
|
|
585
|
+
if not declared:
|
|
586
|
+
return ["DESIGN.md has no reference palette to compare with homepage CSS"]
|
|
587
|
+
measured = measure_design_tokens(homepage.read_text(encoding="utf-8", errors="replace"))
|
|
588
|
+
used = measured["usedColours"]
|
|
589
|
+
errors = []
|
|
590
|
+
for label, colour in declared.items():
|
|
591
|
+
if "derived" in label.lower():
|
|
592
|
+
continue # contrast-safe recommendations, not observed values
|
|
593
|
+
if not used or min(colour_distance(colour, other) for other in used) > tolerance:
|
|
594
|
+
errors.append(f"DESIGN.md palette {label} {colour} is not present in homepage CSS (run measure-design --write)")
|
|
595
|
+
for label, role in (("Background", "canvas"), ("Text primary", "text")):
|
|
596
|
+
if label in declared and colour_distance(declared[label], measured["roles"][role]) > tolerance:
|
|
597
|
+
errors.append(f"DESIGN.md {label} {declared[label]} does not match the measured {role} {measured['roles'][role]}")
|
|
598
|
+
return errors
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def palette_markdown(measured: dict, date: str) -> str:
|
|
602
|
+
roles = measured["roles"]
|
|
603
|
+
lines = [
|
|
604
|
+
"### Reference palette", "",
|
|
605
|
+
f"Measured from `homepage.html` CSS (Tailwind config, arbitrary utilities, inline and",
|
|
606
|
+
f"stylesheet declarations) by `maggie_marketplace.py measure-design` on {date}. Compare",
|
|
607
|
+
"with `screenshots/` before overriding; record any decision below.", "",
|
|
608
|
+
f"- Background: `{roles['canvas']}`",
|
|
609
|
+
f"- Surface: `{roles['panel']}`",
|
|
610
|
+
f"- Soft band: `{roles['soft']}`",
|
|
611
|
+
f"- Text primary: `{roles['text']}`",
|
|
612
|
+
]
|
|
613
|
+
if measured.get("textSecondary"):
|
|
614
|
+
lines.append(f"- Text secondary: `{measured['textSecondary']}`")
|
|
615
|
+
lines += [
|
|
616
|
+
f"- Accent: `{roles['accent']}`",
|
|
617
|
+
f"- Accent text (derived, 4.5:1 on background): `{roles['accentText']}`",
|
|
618
|
+
f"- On accent (derived for contrast): `{roles['onAccent']}`",
|
|
619
|
+
f"- Dark band: `{roles['dark']}`",
|
|
620
|
+
f"- Deep: `{roles['deep']}`",
|
|
621
|
+
f"- Border: `{measured['border']}`",
|
|
622
|
+
]
|
|
623
|
+
return "\n".join(lines) + "\n"
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def write_measured_design(path: Path, measured: dict) -> None:
|
|
627
|
+
design_path = path / "DESIGN.md"
|
|
628
|
+
design = design_path.read_text(encoding="utf-8")
|
|
629
|
+
block = palette_markdown(measured, datetime.now(timezone.utc).date().isoformat())
|
|
630
|
+
pattern = re.compile(r"#{2,4}\s*(?:Reference|Measured)?\s*palette\s*\n.*?(?=\n#{2,4}\s)", re.S | re.I)
|
|
631
|
+
design = pattern.sub(lambda _: block, design, count=1) if pattern.search(design) else design.rstrip() + "\n\n" + block
|
|
632
|
+
fonts = measured["fonts"]
|
|
633
|
+
if fonts.get("display"):
|
|
634
|
+
design = re.sub(r"^- Display:.*$", f"- Display: `{fonts['display']}` (measured); preserve measured weight, line-height, and tracking.", design, count=1, flags=re.M)
|
|
635
|
+
if fonts.get("body"):
|
|
636
|
+
design = re.sub(r"^- Body:.*$", f"- Body: `{fonts['body']}` (measured).", design, count=1, flags=re.M)
|
|
637
|
+
design_path.write_text(design, encoding="utf-8")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def derived_style_tags(measured: dict) -> list[str]:
|
|
641
|
+
roles = measured["roles"]
|
|
642
|
+
tags = ["dark-canvas" if luminance(roles["canvas"]) < 0.2 else "light-canvas"]
|
|
643
|
+
display = (measured["fonts"].get("display") or "").lower()
|
|
644
|
+
tags.append("serif-display" if any(hint in display for hint in SERIF_HINTS) else "sans-display")
|
|
645
|
+
r, _, b = _rgb(roles["canvas"])
|
|
646
|
+
if tags[0] == "light-canvas":
|
|
647
|
+
tags.append("warm-neutral" if r > b + 4 else "cool-neutral" if b > r + 4 else "neutral")
|
|
648
|
+
return tags
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
# T13d: neutral display identity
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _hue_word(colour: str) -> str:
|
|
656
|
+
r, g, b = (c / 255 for c in _rgb(colour))
|
|
657
|
+
hue, light, sat = colorsys.rgb_to_hls(r, g, b)
|
|
658
|
+
if chroma(colour) < 14 or light < 0.08:
|
|
659
|
+
return "Graphite" if light < 0.5 else "Linen"
|
|
660
|
+
degrees = hue * 360
|
|
661
|
+
return min(HUE_WORDS, key=lambda item: min(abs(item[0] - degrees), 360 - abs(item[0] - degrees)))[1]
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def source_brand_tokens(metadata: dict) -> set[str]:
|
|
665
|
+
title = str((metadata.get("sourceMetadata") or {}).get("title") or "")
|
|
666
|
+
head = re.split(r"\s[|—–:-]\s|\s\|\s|\|", title)[0]
|
|
667
|
+
tokens = {token.lower() for token in re.findall(r"[A-Za-z][A-Za-z'’-]{2,}", head)}
|
|
668
|
+
return {token for token in tokens if token not in GENERIC_NAME_WORDS}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def generate_display_identity(metadata: dict, measured: dict, taken: set[str]) -> dict:
|
|
672
|
+
roles = measured["roles"]
|
|
673
|
+
display = (measured["fonts"].get("display") or "").lower()
|
|
674
|
+
style = "Editorial" if any(hint in display for hint in SERIF_HINTS) else "Modern"
|
|
675
|
+
tone = "Noir" if luminance(roles["canvas"]) < 0.2 else "Linen" if _rgb(roles["canvas"])[0] > _rgb(roles["canvas"])[2] + 4 else "Mist"
|
|
676
|
+
industry = [str(item) for item in metadata.get("industry") or []]
|
|
677
|
+
noun = "Clinic" if "medical-spa" in industry and "spa" not in industry[:1] else "Spa" if "spa" in industry else "Wellness" if "wellness" in industry else "Studio"
|
|
678
|
+
hue = _hue_word(roles["accent"])
|
|
679
|
+
brand = source_brand_tokens(metadata)
|
|
680
|
+
candidates = [f"{hue} {style} {noun}", f"{tone} {hue} {noun}", f"{hue} {tone} {style}", f"{tone} {style} {noun}"]
|
|
681
|
+
candidates += [f"{lead} {style} {place}" for lead in (hue, tone) for place in ("Atelier", "House", "Rooms", "Retreat", "Haven", "Parlour")]
|
|
682
|
+
candidates += [f"{lead} {style} {noun} {numeral}" for lead in (hue, tone) for numeral in ("II", "III", "IV", "V", "VI", "VII", "VIII", "IX")]
|
|
683
|
+
name = next((item for item in candidates if item.lower() not in taken and not (set(item.lower().split()) & brand)), None)
|
|
684
|
+
if name is None:
|
|
685
|
+
raise ValueError("could not derive a unique neutral displayName; pass --display-name")
|
|
686
|
+
font = measured["fonts"].get("display") or "system"
|
|
687
|
+
canvas_word = "dark" if tone == "Noir" else "light"
|
|
688
|
+
description = f"A {canvas_word} {noun.lower()} homepage with {font} headings and {hue.lower()} accents."
|
|
689
|
+
return {"displayName": name, "displayDescription": description}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def audit_display_identity(path: Path, catalog: list[dict] | None = None) -> list[str]:
|
|
693
|
+
metadata_path = path / "template.json"
|
|
694
|
+
if not metadata_path.exists():
|
|
695
|
+
return []
|
|
696
|
+
metadata = _read_json(metadata_path)
|
|
697
|
+
name = str(metadata.get("displayName") or "").strip()
|
|
698
|
+
errors = []
|
|
699
|
+
if not name:
|
|
700
|
+
errors.append("template.json needs a neutral displayName for customer-facing pickers")
|
|
701
|
+
if not str(metadata.get("displayDescription") or "").strip():
|
|
702
|
+
errors.append("template.json needs a short displayDescription")
|
|
703
|
+
if name:
|
|
704
|
+
if re.search(r"\b\d{2,}\b", name) or name == metadata.get("name"):
|
|
705
|
+
errors.append(f"displayName must be a style name, not a generic counter: {name}")
|
|
706
|
+
leaked = sorted(set(re.findall(r"[a-z][a-z'’-]{2,}", (name + " " + str(metadata.get("displayDescription") or "")).lower())) & source_brand_tokens(metadata))
|
|
707
|
+
if leaked:
|
|
708
|
+
errors.append(f"displayName/displayDescription repeats the source brand: {', '.join(leaked)}")
|
|
709
|
+
for entry in catalog or []:
|
|
710
|
+
if entry.get("id") != metadata.get("id") and str(entry.get("displayName") or "").strip().lower() == name.lower():
|
|
711
|
+
errors.append(f"displayName is not unique in the catalog: {name} (also {entry.get('id')})")
|
|
712
|
+
return errors
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
# ---------------------------------------------------------------------------
|
|
716
|
+
# T14: template content contract and studio preview
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
CONTENT_SECTIONS = ("brand", "contact", "hero", "categories", "about", "gallery", "reviews", "hours", "faqs")
|
|
720
|
+
CONTENT_FIELD_HELP = {
|
|
721
|
+
"brand.name": "Business name (MaggieDash Settings → Business profile)",
|
|
722
|
+
"brand.tagline": "Short tagline (Business profile)",
|
|
723
|
+
"brand.logo": "Logo image (Business profile → Logo)",
|
|
724
|
+
"contact.phone": "Phone number (Business profile → Contact)",
|
|
725
|
+
"contact.email": "Public email (Business profile → Contact)",
|
|
726
|
+
"contact.address": "Street address (Business profile → Location)",
|
|
727
|
+
"hero.headline": "Homepage headline (Content → Homepage → Hero)",
|
|
728
|
+
"hero.subheadline": "Supporting line under the headline (Content → Homepage → Hero)",
|
|
729
|
+
"hero.image": "Hero image (Content → Homepage → Hero)",
|
|
730
|
+
"hero.cta": "Primary call-to-action label and link (Content → Homepage → Hero)",
|
|
731
|
+
"categories": "Treatment categories and services (Booking → Catalogue)",
|
|
732
|
+
"about": "About section (Content → Homepage → About)",
|
|
733
|
+
"gallery": "Gallery images (Media)",
|
|
734
|
+
"reviews": "Customer reviews (Content → Reviews; never invent reviews)",
|
|
735
|
+
"hours": "Opening hours (Booking → Settings → Opening hours)",
|
|
736
|
+
"faqs": "Frequently asked questions (Content → FAQs)",
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _content_path_exists(content: dict, dotted: str) -> bool:
|
|
741
|
+
node: object = content
|
|
742
|
+
for part in dotted.split("."):
|
|
743
|
+
if isinstance(node, list) and part.isdigit() and int(part) < len(node):
|
|
744
|
+
node = node[int(part)]
|
|
745
|
+
elif isinstance(node, dict) and part in node:
|
|
746
|
+
node = node[part]
|
|
747
|
+
else:
|
|
748
|
+
return False
|
|
749
|
+
return True
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def validate_content(content: dict) -> list[str]:
|
|
753
|
+
errors = []
|
|
754
|
+
if content.get("schemaVersion") != CONTENT_SCHEMA_VERSION:
|
|
755
|
+
errors.append(f"schemaVersion must be {CONTENT_SCHEMA_VERSION}")
|
|
756
|
+
for section in CONTENT_SECTIONS:
|
|
757
|
+
if section not in content:
|
|
758
|
+
errors.append(f"missing section: {section}")
|
|
759
|
+
brand = content.get("brand") if isinstance(content.get("brand"), dict) else {}
|
|
760
|
+
if not str(brand.get("name") or "").strip():
|
|
761
|
+
errors.append("brand.name is required")
|
|
762
|
+
contact = content.get("contact") if isinstance(content.get("contact"), dict) else {}
|
|
763
|
+
if contact.get("email") and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", str(contact["email"])):
|
|
764
|
+
errors.append("contact.email is invalid")
|
|
765
|
+
hero = content.get("hero") if isinstance(content.get("hero"), dict) else {}
|
|
766
|
+
if not str(hero.get("headline") or "").strip():
|
|
767
|
+
errors.append("hero.headline is required")
|
|
768
|
+
categories = content.get("categories")
|
|
769
|
+
if not isinstance(categories, list):
|
|
770
|
+
errors.append("categories must be an array")
|
|
771
|
+
categories = []
|
|
772
|
+
for index, category in enumerate(categories):
|
|
773
|
+
if not isinstance(category, dict) or not str(category.get("name") or "").strip():
|
|
774
|
+
errors.append(f"categories.{index}.name is required")
|
|
775
|
+
continue
|
|
776
|
+
services = category.get("services")
|
|
777
|
+
if not isinstance(services, list):
|
|
778
|
+
errors.append(f"categories.{index}.services must be an array")
|
|
779
|
+
continue
|
|
780
|
+
for position, service in enumerate(services):
|
|
781
|
+
label = f"categories.{index}.services.{position}"
|
|
782
|
+
if not isinstance(service, dict) or not str(service.get("name") or "").strip():
|
|
783
|
+
errors.append(f"{label}.name is required")
|
|
784
|
+
continue
|
|
785
|
+
price = service.get("price")
|
|
786
|
+
if price is not None and (not isinstance(price, (int, float)) or price < 0):
|
|
787
|
+
errors.append(f"{label}.price must be a non-negative number or null")
|
|
788
|
+
duration = service.get("durationMinutes")
|
|
789
|
+
if duration is not None and (not isinstance(duration, int) or duration <= 0):
|
|
790
|
+
errors.append(f"{label}.durationMinutes must be a positive integer or null")
|
|
791
|
+
for key in ("gallery", "reviews", "faqs"):
|
|
792
|
+
if key in content and not isinstance(content[key], list):
|
|
793
|
+
errors.append(f"{key} must be an array")
|
|
794
|
+
for position, review in enumerate(content.get("reviews") or []):
|
|
795
|
+
if isinstance(review, dict) and review.get("sample") is not True and not review.get("source"):
|
|
796
|
+
errors.append(f"reviews.{position} needs a source, or sample=true for demo copy")
|
|
797
|
+
hours = content.get("hours")
|
|
798
|
+
if hours is not None and not isinstance(hours, list):
|
|
799
|
+
errors.append("hours must be an array of {day, opens, closes} or {day, closed}")
|
|
800
|
+
for position, item in enumerate(hours or []):
|
|
801
|
+
if not isinstance(item, dict) or item.get("day") not in {"mon", "tue", "wed", "thu", "fri", "sat", "sun"}:
|
|
802
|
+
errors.append(f"hours.{position}.day must be mon..sun")
|
|
803
|
+
elif not item.get("closed") and not (re.fullmatch(r"\d{2}:\d{2}", str(item.get("opens") or "")) and re.fullmatch(r"\d{2}:\d{2}", str(item.get("closes") or ""))):
|
|
804
|
+
errors.append(f"hours.{position} needs opens/closes as HH:MM or closed=true")
|
|
805
|
+
samples = content.get("samples", [])
|
|
806
|
+
if not isinstance(samples, list):
|
|
807
|
+
errors.append("samples must be an array of content paths")
|
|
808
|
+
samples = []
|
|
809
|
+
for dotted in samples:
|
|
810
|
+
if not isinstance(dotted, str) or not _content_path_exists(content, dotted):
|
|
811
|
+
errors.append(f"samples entry does not resolve to content: {dotted}")
|
|
812
|
+
palette = content.get("palette")
|
|
813
|
+
if palette is not None:
|
|
814
|
+
roles = palette.get("roles") if isinstance(palette, dict) else None
|
|
815
|
+
if not isinstance(roles, dict) or any(not re.fullmatch(r"#[0-9A-Fa-f]{6}", str(roles.get(role) or "")) for role in PALETTE_ROLES):
|
|
816
|
+
errors.append("palette.roles must define every maggie-palette-roles-v1 role as #RRGGBB")
|
|
817
|
+
return errors
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def palette_css_variables(roles: dict) -> str:
|
|
821
|
+
def kebab(name: str) -> str:
|
|
822
|
+
return re.sub(r"([A-Z])", lambda match: "-" + match.group(1).lower(), name)
|
|
823
|
+
|
|
824
|
+
return ":root {\n" + "".join(f" --maggie-{kebab(role)}: {roles[role]};\n" for role in PALETTE_ROLES if role in roles) + "}\n"
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def load_palettes(path: Path | None, measured: dict, template_id: str) -> dict:
|
|
828
|
+
if path is not None:
|
|
829
|
+
palettes = _read_json(path)
|
|
830
|
+
if palettes.get("schemaVersion") != "maggie-palette-roles-v1":
|
|
831
|
+
raise ValueError("palette file must use schemaVersion maggie-palette-roles-v1")
|
|
832
|
+
return palettes
|
|
833
|
+
return {"schemaVersion": "maggie-palette-roles-v1", "template": template_id,
|
|
834
|
+
"signature": {"id": "signature", "name": "Signature (measured)", "roles": measured["roles"]}, "variants": []}
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
STUDIO_SCRIPT = r"""
|
|
838
|
+
const DATA = JSON.parse(document.getElementById('maggie-studio-data').textContent);
|
|
839
|
+
const select = document.getElementById('palette');
|
|
840
|
+
const labels = document.getElementById('labels');
|
|
841
|
+
const report = {};
|
|
842
|
+
const palettes = [DATA.palettes.signature, ...(DATA.palettes.variants || [])];
|
|
843
|
+
palettes.forEach((palette, index) => { const option = document.createElement('option'); option.value = index; option.textContent = palette.name || palette.id; select.append(option); });
|
|
844
|
+
select.value = String(Math.max(0, palettes.findIndex((palette) => '#' + palette.id === location.hash)));
|
|
845
|
+
select.addEventListener('change', () => history.replaceState(null, '', '#' + palettes[select.value].id));
|
|
846
|
+
const sourcePromise = fetch(DATA.homepage).then((response) => { if (!response.ok) throw new Error('homepage ' + response.status); return response.text(); });
|
|
847
|
+
function kebab(value) { return value.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); }
|
|
848
|
+
function themed(source, palette) {
|
|
849
|
+
let output = source;
|
|
850
|
+
const signature = DATA.palettes.signature.roles;
|
|
851
|
+
for (const [role, colour] of Object.entries(signature)) {
|
|
852
|
+
const target = palette.roles[role];
|
|
853
|
+
if (target && colour && target.toLowerCase() !== colour.toLowerCase()) output = output.replace(new RegExp(colour.replace('#', '#?'), 'gi'), target);
|
|
854
|
+
}
|
|
855
|
+
const vars = ':root{' + Object.entries(palette.roles).map(([role, colour]) => `--maggie-${kebab(role)}:${colour};`).join('') + '}';
|
|
856
|
+
const head = `<base href="${DATA.base}"><style id="maggie-studio-palette">${vars}</style><style>[data-maggie-sample-label]{outline:2px dashed #d97706!important;outline-offset:2px;position:relative}.maggie-sample-badge{position:absolute;z-index:2147483647;font:600 11px/1.3 system-ui,sans-serif;background:#d97706;color:#fff;padding:2px 6px;border-radius:4px;text-decoration:none;pointer-events:auto}</style>`;
|
|
857
|
+
return output.replace(/<head([^>]*)>/i, (match) => match + head);
|
|
858
|
+
}
|
|
859
|
+
function guessSamples(doc) {
|
|
860
|
+
const found = [...doc.querySelectorAll('[data-maggie-sample]')].map((el) => [el, el.dataset.maggieSample]);
|
|
861
|
+
if (found.length) return found;
|
|
862
|
+
const guesses = [];
|
|
863
|
+
const h1 = doc.querySelector('h1'); if (h1) { guesses.push([h1, 'hero.headline']); const p = h1.parentElement && h1.parentElement.querySelector('p'); if (p) guesses.push([p, 'hero.subheadline']); }
|
|
864
|
+
doc.querySelectorAll('a[href^="tel:"]').forEach((el) => guesses.push([el, 'contact.phone']));
|
|
865
|
+
doc.querySelectorAll('a[href^="mailto:"]').forEach((el) => guesses.push([el, 'contact.email']));
|
|
866
|
+
doc.querySelectorAll('address').forEach((el) => guesses.push([el, 'contact.address']));
|
|
867
|
+
doc.querySelectorAll('section, footer').forEach((section) => {
|
|
868
|
+
const text = (section.querySelector('h2,h3') || {}).textContent || '';
|
|
869
|
+
if (/faq|question/i.test(text)) guesses.push([section, 'faqs']);
|
|
870
|
+
else if (/review|testimonial|clients say/i.test(text)) guesses.push([section, 'reviews']);
|
|
871
|
+
else if (/hours|opening/i.test(text)) guesses.push([section, 'hours']);
|
|
872
|
+
else if (/treatment|service|menu|price/i.test(text)) guesses.push([section, 'categories']);
|
|
873
|
+
else if (/about|story|philosophy/i.test(text)) guesses.push([section, 'about']);
|
|
874
|
+
else if (/gallery|space|journal/i.test(text)) guesses.push([section, 'gallery']);
|
|
875
|
+
});
|
|
876
|
+
return guesses;
|
|
877
|
+
}
|
|
878
|
+
function label(doc, show) {
|
|
879
|
+
doc.querySelectorAll('.maggie-sample-badge').forEach((el) => el.remove());
|
|
880
|
+
doc.querySelectorAll('[data-maggie-sample-label]').forEach((el) => el.removeAttribute('data-maggie-sample-label'));
|
|
881
|
+
if (!show) return [];
|
|
882
|
+
const seen = [];
|
|
883
|
+
for (const [element, field] of guessSamples(doc)) {
|
|
884
|
+
element.setAttribute('data-maggie-sample-label', field);
|
|
885
|
+
const badge = doc.createElement('a');
|
|
886
|
+
badge.className = 'maggie-sample-badge'; badge.textContent = 'Sample · ' + field; badge.target = '_top';
|
|
887
|
+
badge.href = DATA.fieldsPage + '#' + field.split('.')[0] + (field.includes('.') ? '-' + field.split('.')[1] : '');
|
|
888
|
+
element.prepend(badge); seen.push(field);
|
|
889
|
+
}
|
|
890
|
+
return seen;
|
|
891
|
+
}
|
|
892
|
+
function measure(frame, width) {
|
|
893
|
+
const doc = frame.contentDocument; if (!doc) return;
|
|
894
|
+
const root = doc.documentElement; const overflow = Math.max(0, root.scrollWidth - root.clientWidth);
|
|
895
|
+
const offenders = overflow ? [...doc.body.querySelectorAll('*')].filter((el) => el.getBoundingClientRect().right > root.clientWidth + 1).slice(0, 5).map((el) => el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).slice(0, 3).join('.') : '')) : [];
|
|
896
|
+
report[width] = { overflowPx: overflow, offenders, samples: label(doc, labels.checked) };
|
|
897
|
+
document.getElementById('badge-' + width).textContent = overflow ? `overflow ${overflow}px` : 'no overflow';
|
|
898
|
+
document.getElementById('badge-' + width).className = overflow ? 'badge bad' : 'badge ok';
|
|
899
|
+
document.getElementById('maggie-studio-report').textContent = JSON.stringify({ palette: palettes[select.value].id, viewports: report });
|
|
900
|
+
}
|
|
901
|
+
async function render() {
|
|
902
|
+
const palette = palettes[select.value];
|
|
903
|
+
let source;
|
|
904
|
+
try { source = await sourcePromise; } catch (error) { document.getElementById('maggie-studio-report').textContent = JSON.stringify({ error: String(error) }); return; }
|
|
905
|
+
for (const width of DATA.viewports) {
|
|
906
|
+
const frame = document.getElementById('frame-' + width);
|
|
907
|
+
frame.onload = () => setTimeout(() => measure(frame, width), DATA.settleMs);
|
|
908
|
+
frame.srcdoc = themed(source, palette);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
select.addEventListener('change', render);
|
|
912
|
+
labels.addEventListener('change', () => DATA.viewports.forEach((width) => { const frame = document.getElementById('frame-' + width); if (frame.contentDocument) measure(frame, width); }));
|
|
913
|
+
function fit() { DATA.viewports.forEach((width) => { const cell = document.getElementById('cell-' + width); const scale = Math.min(1, (cell.clientWidth - 2) / width); const frame = document.getElementById('frame-' + width); frame.style.transform = `scale(${scale})`; cell.style.height = (DATA.height * scale + 2) + 'px'; }); }
|
|
914
|
+
window.addEventListener('resize', fit); fit(); render();
|
|
915
|
+
"""
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def build_studio(path: Path, output: Path, palettes: dict, height: int = 900, settle_ms: int = 1500) -> dict:
|
|
919
|
+
"""Write a static studio page: template × palette × viewport with sample labels."""
|
|
920
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
921
|
+
homepage = (path / "homepage.html").resolve()
|
|
922
|
+
rel_home = Path(_relative(homepage, output.resolve()))
|
|
923
|
+
base = rel_home.parent.as_posix().rstrip("/") + "/"
|
|
924
|
+
metadata = _read_json(path / "template.json") if (path / "template.json").exists() else {}
|
|
925
|
+
title = metadata.get("displayName") or metadata.get("id") or path.name
|
|
926
|
+
data = {"homepage": rel_home.as_posix(), "base": base, "viewports": list(STUDIO_VIEWPORTS), "height": height,
|
|
927
|
+
"settleMs": settle_ms, "palettes": palettes, "fieldsPage": "content-fields.html"}
|
|
928
|
+
cells = "".join(
|
|
929
|
+
f'<figure><figcaption>{width}px <span class="badge" id="badge-{width}">measuring…</span></figcaption>'
|
|
930
|
+
f'<div class="cell" id="cell-{width}"><iframe id="frame-{width}" title="{width}px preview" width="{width}" height="{height}" '
|
|
931
|
+
f'style="width:{width}px;height:{height}px"></iframe></div></figure>'
|
|
932
|
+
for width in STUDIO_VIEWPORTS
|
|
933
|
+
)
|
|
934
|
+
payload = json.dumps(data).replace("</", "<\\/")
|
|
935
|
+
page = f"""<!doctype html>
|
|
936
|
+
<html lang="en-GB"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
937
|
+
<title>{html_lib.escape(str(title))} studio</title>
|
|
938
|
+
<style>
|
|
939
|
+
body{{margin:0;font:14px/1.5 system-ui,sans-serif;background:#f4f4f2;color:#1c1c1a}}
|
|
940
|
+
header{{display:flex;flex-wrap:wrap;gap:16px;align-items:center;padding:12px 16px;background:#fff;border-bottom:1px solid #ddd;position:sticky;top:0;z-index:1}}
|
|
941
|
+
main{{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;padding:16px}}
|
|
942
|
+
figure{{margin:0}} figcaption{{display:flex;justify-content:space-between;margin-bottom:6px;font-weight:600}}
|
|
943
|
+
.cell{{overflow:hidden;border:1px solid #ccc;background:#fff}} iframe{{border:0;transform-origin:0 0;display:block}}
|
|
944
|
+
.badge{{font-weight:500;padding:0 6px;border-radius:4px}} .ok{{background:#dcfce7;color:#166534}} .bad{{background:#fee2e2;color:#991b1b}}
|
|
945
|
+
#maggie-studio-report{{margin:0 16px 16px;white-space:pre-wrap;font-size:12px;color:#555}}
|
|
946
|
+
</style></head><body>
|
|
947
|
+
<header><strong>{html_lib.escape(str(title))}</strong>
|
|
948
|
+
<label>Palette <select id="palette"></select></label>
|
|
949
|
+
<label><input type="checkbox" id="labels" checked> Show sample labels</label>
|
|
950
|
+
<a href="content-fields.html">Where owners replace sample content</a></header>
|
|
951
|
+
<main>{cells}</main>
|
|
952
|
+
<pre id="maggie-studio-report">{{}}</pre>
|
|
953
|
+
<script type="application/json" id="maggie-studio-data">{payload}</script>
|
|
954
|
+
<script>{STUDIO_SCRIPT}</script>
|
|
955
|
+
</body></html>
|
|
956
|
+
"""
|
|
957
|
+
(output / "index.html").write_text(page, encoding="utf-8")
|
|
958
|
+
rows = "".join(
|
|
959
|
+
f'<tr id="{field.replace(".", "-")}"><td><code>{field}</code></td><td>{html_lib.escape(text)}</td></tr>'
|
|
960
|
+
for field, text in CONTENT_FIELD_HELP.items()
|
|
961
|
+
)
|
|
962
|
+
(output / "content-fields.html").write_text(
|
|
963
|
+
"<!doctype html><html lang=\"en-GB\"><head><meta charset=\"utf-8\"><title>Sample content fields</title>"
|
|
964
|
+
"<style>body{font:14px/1.5 system-ui,sans-serif;margin:24px;max-width:860px}td{padding:6px 10px;border-bottom:1px solid #ddd;vertical-align:top}tr:target{background:#fef3c7}</style></head><body>"
|
|
965
|
+
f"<h1>Sample content fields</h1><p>Each labelled sample in the studio maps to a <code>{CONTENT_SCHEMA_VERSION}</code> field. "
|
|
966
|
+
"Owners replace it in the location listed; the template renders the content contract instead of hand-edited copy.</p>"
|
|
967
|
+
f"<table>{rows}</table></body></html>\n",
|
|
968
|
+
encoding="utf-8",
|
|
969
|
+
)
|
|
970
|
+
_write_json(output / "palettes.json", palettes)
|
|
971
|
+
return {"studio": str(output / "index.html"), "viewports": list(STUDIO_VIEWPORTS), "palettes": 1 + len(palettes.get("variants") or [])}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _relative(target: Path, start: Path) -> str:
|
|
975
|
+
import os
|
|
976
|
+
|
|
977
|
+
return os.path.relpath(target, start)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def parse_studio_report(dom: str) -> dict:
|
|
981
|
+
match = re.search(r'<pre id="maggie-studio-report">(.*?)</pre>', dom, re.S)
|
|
982
|
+
if not match:
|
|
983
|
+
raise ValueError("studio report was not rendered")
|
|
984
|
+
return json.loads(html_lib.unescape(match.group(1)) or "{}")
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def check_studio_overflow(serve_root: Path, studio_index: Path, budget_ms: int = 12000, palette_id: str | None = None) -> dict:
|
|
988
|
+
"""Render the studio in headless Chrome and return the per-viewport overflow report."""
|
|
989
|
+
chrome = shutil.which("google-chrome") or shutil.which("chromium") or shutil.which("chromium-browser")
|
|
990
|
+
if not chrome:
|
|
991
|
+
raise RuntimeError("google-chrome or chromium is required for --check-overflow")
|
|
992
|
+
handler = partial(_QuietHandler, directory=str(serve_root))
|
|
993
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
994
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
995
|
+
thread.start()
|
|
996
|
+
try:
|
|
997
|
+
url = f"http://127.0.0.1:{server.server_port}/{studio_index.resolve().relative_to(serve_root.resolve()).as_posix()}"
|
|
998
|
+
if palette_id:
|
|
999
|
+
url += "#" + palette_id
|
|
1000
|
+
proc = subprocess.run([chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
|
|
1001
|
+
"--window-size=1600,1200", f"--virtual-time-budget={budget_ms}", "--dump-dom", url],
|
|
1002
|
+
capture_output=True, text=True, timeout=120)
|
|
1003
|
+
if proc.returncode != 0:
|
|
1004
|
+
raise RuntimeError(f"headless chrome failed: {(proc.stderr or '')[-300:]}")
|
|
1005
|
+
return parse_studio_report(proc.stdout)
|
|
1006
|
+
finally:
|
|
1007
|
+
server.shutdown()
|
|
1008
|
+
server.server_close()
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
class _QuietHandler(SimpleHTTPRequestHandler):
|
|
1012
|
+
def log_message(self, *_args) -> None: # noqa: D401 - silence request logging
|
|
1013
|
+
return
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
# ---------------------------------------------------------------------------
|
|
1017
|
+
# T15: image generation cost report and no-text review gate
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _generated_assets(path: Path) -> list[dict]:
|
|
1021
|
+
manifest = path / "generated-asset-manifest.json"
|
|
1022
|
+
if not manifest.exists():
|
|
1023
|
+
return []
|
|
1024
|
+
inherited = manifest_provider(manifest)
|
|
1025
|
+
return [item for item in _read_json(manifest).get("assets", [])
|
|
1026
|
+
if isinstance(item, dict) and item.get("path") and (item.get("provider") or inherited) not in NON_GENERATED_PROVIDERS
|
|
1027
|
+
and (path / item["path"]).is_file()]
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def plan_image_generation(path: Path, unit_cost: float | None, currency: str, provider: str) -> dict:
|
|
1031
|
+
planned = 0
|
|
1032
|
+
inventory = path / "image-inventory.json"
|
|
1033
|
+
if inventory.exists():
|
|
1034
|
+
planned += sum(1 for item in _read_json(inventory).get("occurrences", []) if item.get("status") == "needs-generation")
|
|
1035
|
+
manifest = path / "generated-asset-manifest.json"
|
|
1036
|
+
if manifest.exists():
|
|
1037
|
+
planned += sum(1 for item in _read_json(manifest).get("assets", []) if item.get("provider") in {"pending", "", None})
|
|
1038
|
+
planned += sum(1 for item in _read_json(manifest).get("assets", []) if item.get("provider") == CAPTURED_PROVIDER)
|
|
1039
|
+
report_path = path / "image-generation-report.json"
|
|
1040
|
+
report = _read_json(report_path) if report_path.exists() else {"schemaVersion": REPORT_SCHEMA_VERSION}
|
|
1041
|
+
report["before"] = {
|
|
1042
|
+
"createdAt": now(), "provider": provider, "plannedImages": planned, "unitCost": unit_cost, "currency": currency,
|
|
1043
|
+
"estimatedCost": round(planned * unit_cost, 4) if unit_cost is not None else None,
|
|
1044
|
+
"note": None if unit_cost is not None else "unit cost not supplied; pass --unit-cost from the provider's current price list",
|
|
1045
|
+
}
|
|
1046
|
+
report["generatedBefore"] = sorted(item["path"] for item in _generated_assets(path))
|
|
1047
|
+
_write_json(report_path, report)
|
|
1048
|
+
return report
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _ocr_words(image: Path) -> list[str] | None:
|
|
1052
|
+
tesseract = shutil.which("tesseract")
|
|
1053
|
+
if not tesseract or image.suffix.lower() == ".svg":
|
|
1054
|
+
return None
|
|
1055
|
+
proc = subprocess.run([tesseract, str(image), "stdout", "--psm", "11"], capture_output=True, text=True, timeout=120)
|
|
1056
|
+
if proc.returncode != 0:
|
|
1057
|
+
return None
|
|
1058
|
+
return [word for word in re.findall(r"[A-Za-z]{3,}", proc.stdout)]
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def review_generated_images(path: Path, approve: bool = False, reviewer: str = "", flags: list[str] | None = None, note: str = "") -> dict:
|
|
1062
|
+
report_path = path / "image-generation-report.json"
|
|
1063
|
+
report = _read_json(report_path) if report_path.exists() else {"schemaVersion": REPORT_SCHEMA_VERSION}
|
|
1064
|
+
assets = _generated_assets(path)
|
|
1065
|
+
before = set(report.get("generatedBefore") or [])
|
|
1066
|
+
new_images = [item for item in assets if item["path"] not in before] if "generatedBefore" in report else assets
|
|
1067
|
+
plan = report.get("before") or {}
|
|
1068
|
+
unit_cost = plan.get("unitCost")
|
|
1069
|
+
report["after"] = {
|
|
1070
|
+
"reviewedAt": now(), "generatedImages": len(new_images), "totalGeneratedAssets": len(assets),
|
|
1071
|
+
"unitCost": unit_cost, "currency": plan.get("currency", "USD"),
|
|
1072
|
+
"estimatedCost": round(len(new_images) * unit_cost, 4) if isinstance(unit_cost, (int, float)) else None,
|
|
1073
|
+
"plannedImages": plan.get("plannedImages"),
|
|
1074
|
+
}
|
|
1075
|
+
review_dir = path / "image-review"
|
|
1076
|
+
review_dir.mkdir(parents=True, exist_ok=True)
|
|
1077
|
+
tiles = "".join(
|
|
1078
|
+
f'<figure><img src="../{html_lib.escape(item["path"])}" alt=""><figcaption>{html_lib.escape(item["path"])}<br>{html_lib.escape(str(item.get("role", "")))}</figcaption></figure>'
|
|
1079
|
+
for item in assets
|
|
1080
|
+
)
|
|
1081
|
+
(review_dir / "contact-sheet.html").write_text(
|
|
1082
|
+
"<!doctype html><html lang=\"en-GB\"><head><meta charset=\"utf-8\"><title>Generated image contact sheet</title>"
|
|
1083
|
+
"<style>body{font:13px system-ui,sans-serif;margin:16px}main{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}"
|
|
1084
|
+
"img{width:100%;height:260px;object-fit:contain;background:#eee}figure{margin:0}</style></head><body>"
|
|
1085
|
+
"<h1>Check every image for readable text, labels, logos, and watermarks</h1>"
|
|
1086
|
+
f"<main>{tiles}</main></body></html>\n",
|
|
1087
|
+
encoding="utf-8",
|
|
1088
|
+
)
|
|
1089
|
+
flagged = set(flags or [])
|
|
1090
|
+
items, method = [], "ocr" if shutil.which("tesseract") else "human"
|
|
1091
|
+
for item in assets:
|
|
1092
|
+
words = _ocr_words(path / item["path"]) if method == "ocr" else None
|
|
1093
|
+
status = "flagged" if item["path"] in flagged or words else "passed" if (words == [] or approve) else "pending"
|
|
1094
|
+
items.append({"path": item["path"], "role": item.get("role"), "status": status,
|
|
1095
|
+
**({"ocrWords": words[:12]} if words else {})})
|
|
1096
|
+
if any(item["status"] == "flagged" for item in items):
|
|
1097
|
+
status = "flagged"
|
|
1098
|
+
elif all(item["status"] == "passed" for item in items) and (method == "ocr" or approve):
|
|
1099
|
+
status = "passed"
|
|
1100
|
+
else:
|
|
1101
|
+
status = "pending-human-review"
|
|
1102
|
+
if approve and not reviewer:
|
|
1103
|
+
raise ValueError("--approve requires --reviewer")
|
|
1104
|
+
report["textReview"] = {
|
|
1105
|
+
"status": status, "method": "ocr+human" if method == "ocr" and approve else method,
|
|
1106
|
+
"reviewer": reviewer or None, "note": note or None, "contactSheet": "image-review/contact-sheet.html",
|
|
1107
|
+
"reviewedAt": now(), "items": items,
|
|
1108
|
+
}
|
|
1109
|
+
_write_json(report_path, report)
|
|
1110
|
+
return report
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def audit_image_review(path: Path) -> list[str]:
|
|
1114
|
+
assets = _generated_assets(path)
|
|
1115
|
+
if not assets:
|
|
1116
|
+
return []
|
|
1117
|
+
report_path = path / "image-generation-report.json"
|
|
1118
|
+
if not report_path.exists():
|
|
1119
|
+
return ["generated images need a no-text review: run image-review (contact sheet/OCR) and record the result"]
|
|
1120
|
+
report = _read_json(report_path)
|
|
1121
|
+
review = report.get("textReview") or {}
|
|
1122
|
+
if not review:
|
|
1123
|
+
return ["generated images need a no-text review: run image-review (contact sheet/OCR) and record the result"]
|
|
1124
|
+
if review.get("status") != "passed":
|
|
1125
|
+
return [f"generated image text review is {review.get('status', 'missing')}; resolve flagged or pending images before completion"]
|
|
1126
|
+
reviewed = {item.get("path") for item in review.get("items", []) if item.get("status") == "passed"}
|
|
1127
|
+
missing = sorted(item["path"] for item in assets if item["path"] not in reviewed)
|
|
1128
|
+
return [f"generated image was not covered by the text review: {name}" for name in missing]
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def package_audit(path: Path, catalog: list[dict] | None = None) -> dict:
|
|
1132
|
+
metadata = _read_json(path / "template.json") if (path / "template.json").exists() else {}
|
|
1133
|
+
published = metadata.get("status") in {"published", "ready"}
|
|
1134
|
+
groups = {
|
|
1135
|
+
"screenshots": audit_screenshots(path),
|
|
1136
|
+
"designPalette": audit_design_palette(path),
|
|
1137
|
+
"assetProvenance": audit_asset_provenance(path, published=published),
|
|
1138
|
+
"displayIdentity": audit_display_identity(path, catalog),
|
|
1139
|
+
"imageReview": audit_image_review(path),
|
|
1140
|
+
}
|
|
1141
|
+
return {"template": metadata.get("id") or path.name, "passed": not any(groups.values()), "checks": groups}
|