@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.
Files changed (91) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +76 -15
  3. package/bin/maggie.js +23 -6
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  9. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  10. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  11. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  12. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  13. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  14. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  15. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  16. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  17. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  18. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  19. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  20. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  21. package/bundled-marketplace/README.md +17 -0
  22. package/bundled-marketplace/catalog.json +56 -7
  23. package/bundled-references/browser-inspection.md +25 -0
  24. package/bundled-references/google-integrations-runbook.md +54 -0
  25. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  26. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  27. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  28. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  29. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  30. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  31. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  32. package/bundled-references/maggiedash-booking/README.md +23 -6
  33. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  34. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  35. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  36. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  37. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  38. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  39. package/bundled-references/provider-adapter-contract.md +33 -0
  40. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  41. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  42. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  43. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  44. package/bundled-skills/maggie-deployment/SKILL.md +141 -7
  45. package/bundled-skills/maggie-deployment/references/vps.md +74 -1
  46. package/bundled-skills/maggie-design/SKILL.md +154 -0
  47. package/bundled-skills/maggie-feedback/SKILL.md +67 -1
  48. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  49. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  50. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  51. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  52. package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
  53. package/bundled-skills/maggie-social-share/SKILL.md +56 -7
  54. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  55. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
  56. package/bundled-skills/maggie-template/SKILL.md +57 -3
  57. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  58. package/bundled-tools/clis/maggie_booking.py +199 -26
  59. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  60. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  61. package/bundled-tools/clis/maggie_deployment.py +307 -18
  62. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  63. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  64. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  65. package/bundled-tools/clis/maggie_design.py +17 -0
  66. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  67. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  68. package/bundled-tools/clis/maggie_docs.py +82 -4
  69. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  70. package/bundled-tools/clis/maggie_feedback.py +115 -5
  71. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  72. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  73. package/bundled-tools/clis/maggie_localization.py +5 -0
  74. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  75. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  76. package/bundled-tools/clis/maggie_notifications.py +528 -0
  77. package/bundled-tools/clis/maggie_ops.py +87 -0
  78. package/bundled-tools/clis/maggie_palette.py +280 -0
  79. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  80. package/bundled-tools/clis/maggie_schedule.py +12 -2
  81. package/bundled-tools/clis/maggie_service_import.py +520 -0
  82. package/bundled-tools/clis/maggie_social_queue.py +442 -0
  83. package/bundled-tools/clis/maggie_social_share.py +46 -9
  84. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  85. package/bundled-tools/runtime/google_ads.py +306 -0
  86. package/bundled-tools/runtime/google_capabilities.py +18 -2
  87. package/bundled-tools/runtime/google_remediation.py +152 -0
  88. package/package.json +1 -1
  89. package/references/browser-inspection.md +25 -0
  90. package/references/google-integrations-runbook.md +54 -0
  91. 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}