@topy-ai/maggie 0.7.48 → 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 (49) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +31 -4
  3. package/bin/maggie.js +17 -4
  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-ops/marketing-run-v1.schema.json +89 -0
  9. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  10. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  11. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  12. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  13. package/bundled-references/google-integrations-runbook.md +54 -0
  14. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  15. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  16. package/bundled-skills/maggie-deployment/SKILL.md +89 -7
  17. package/bundled-skills/maggie-deployment/references/vps.md +38 -6
  18. package/bundled-skills/maggie-design/SKILL.md +55 -0
  19. package/bundled-skills/maggie-feedback/SKILL.md +29 -0
  20. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  21. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  22. package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
  23. package/bundled-skills/maggie-social-share/SKILL.md +19 -2
  24. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  25. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
  26. package/bundled-tools/clis/maggie_deployment.py +58 -17
  27. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  28. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  29. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  30. package/bundled-tools/clis/maggie_design.py +8 -0
  31. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  32. package/bundled-tools/clis/maggie_docs.py +82 -4
  33. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  34. package/bundled-tools/clis/maggie_feedback.py +9 -2
  35. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  36. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  37. package/bundled-tools/clis/maggie_localization.py +5 -0
  38. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  39. package/bundled-tools/clis/maggie_notifications.py +528 -0
  40. package/bundled-tools/clis/maggie_ops.py +87 -0
  41. package/bundled-tools/clis/maggie_schedule.py +12 -2
  42. package/bundled-tools/clis/maggie_service_import.py +520 -0
  43. package/bundled-tools/clis/maggie_social_queue.py +104 -0
  44. package/bundled-tools/clis/maggie_social_share.py +6 -2
  45. package/bundled-tools/runtime/google_ads.py +306 -0
  46. package/bundled-tools/runtime/google_capabilities.py +18 -2
  47. package/bundled-tools/runtime/google_remediation.py +152 -0
  48. package/package.json +1 -1
  49. package/references/google-integrations-runbook.md +54 -0
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env python3
2
+ """Collect rendered icon-glyph evidence in headless Chrome.
3
+
4
+ Source-to-runtime inventory proves names, not pixels. This collector renders
5
+ representative icons on each route and viewport and records, per icon:
6
+
7
+ - whether its icon font is in ``document.fonts`` with status ``loaded``;
8
+ - whether the glyph paints ink that differs from the browser fallback font
9
+ (a missing glyph falls back to another font or a blank box);
10
+ - whether runtime CSS hides it (``display``, ``visibility``, ``opacity``,
11
+ zero size, or a ``content: none`` pseudo-element);
12
+ - its family, accessible name, and the HTTP status of the icon font assets.
13
+
14
+ The target is either a running host (``--base-url``, proxied through a local
15
+ read-only GET proxy so the probe and fonts share one origin) or a static build
16
+ (``--dist``). Only GET requests are forwarded; the report never stores page
17
+ text beyond icon names and accessible labels.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import html as html_lib
24
+ import json
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import threading
30
+ from datetime import datetime, timezone
31
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
32
+ from pathlib import Path
33
+ from urllib.error import HTTPError, URLError
34
+ from urllib.parse import urlsplit
35
+ from urllib.request import Request, urlopen
36
+
37
+ SCHEMA = "maggie-icon-browser-evidence.v1"
38
+ COLLECTOR = "maggie-icon-glyph-check"
39
+ DEFAULT_VIEWPORTS = {"mobile": (390, 844), "tablet": (768, 1024), "desktop": (1440, 900)}
40
+ FONT_EXTENSIONS = (".woff2", ".woff", ".ttf", ".otf", ".eot")
41
+ REPORT_ID = "maggie-icon-probe-report"
42
+ DEFAULT_SELECTOR = (
43
+ "i[class*='ph'], span[class*='ph-'], [class^='ph-'], [class*=' ph-'], i[class*='fa-'], span[class*='fa-'], "
44
+ "[class*='lucide'], [data-icon], [data-glyph], .material-icons, .material-symbols-outlined, "
45
+ ".material-symbols-rounded, .bi, [class*='bi-']"
46
+ )
47
+
48
+ PROBE = r"""
49
+ (() => {
50
+ const config = __CONFIG__;
51
+ const out = document.createElement('pre');
52
+ out.id = config.reportId; out.hidden = true;
53
+ const norm = (value) => String(value || '').split(',')[0].trim().replace(/^['"]|['"]$/g, '').toLowerCase();
54
+ const nameOf = (el) => {
55
+ const attr = el.getAttribute('data-icon') || el.getAttribute('data-glyph');
56
+ if (attr) return attr;
57
+ for (const token of Array.from(el.classList)) {
58
+ const match = token.match(/^(?:ph|fa|lucide|bi)-([a-z0-9-]+)$/i);
59
+ if (match && !/^(?:thin|light|regular|bold|fill|duotone|solid|brands|fw|lg|xl)$/i.test(match[1])) return match[1];
60
+ }
61
+ const text = (el.textContent || '').trim();
62
+ if (/material/.test(el.className) && text) return text;
63
+ return el.tagName.toLowerCase() === 'svg' ? (el.getAttribute('data-lucide') || 'svg') : (el.className || el.tagName).toString().slice(0, 60);
64
+ };
65
+ const hiddenBy = (el) => {
66
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
67
+ const style = getComputedStyle(node);
68
+ if (style.display === 'none') return node === el ? 'display:none' : 'ancestor display:none';
69
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return 'visibility:' + style.visibility;
70
+ if (Number(style.opacity) === 0) return node === el ? 'opacity:0' : 'ancestor opacity:0';
71
+ if (node === document.body) break;
72
+ }
73
+ return null;
74
+ };
75
+ const accessibleName = (el) => {
76
+ const direct = el.getAttribute('aria-label') || el.getAttribute('title');
77
+ if (direct && direct.trim()) return { name: direct.trim(), decorative: false };
78
+ const labelledby = el.getAttribute('aria-labelledby');
79
+ if (labelledby) {
80
+ const text = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ').trim();
81
+ if (text) return { name: text, decorative: false };
82
+ }
83
+ const control = el.closest('a,button,[role=button],[role=link],label,summary');
84
+ if (control) {
85
+ const label = control.getAttribute('aria-label') || control.getAttribute('title') || (control.textContent || '').replace(/\s+/g, ' ').trim();
86
+ if (label) return { name: label.slice(0, 120), decorative: el.getAttribute('aria-hidden') === 'true' };
87
+ }
88
+ const parentText = (el.parentElement?.textContent || '').replace(/\s+/g, ' ').trim();
89
+ return { name: parentText.slice(0, 120), decorative: true };
90
+ };
91
+ const pixels = (font, text) => {
92
+ const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64;
93
+ const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, 64, 64); ctx.fillStyle = '#000';
94
+ ctx.textBaseline = 'middle'; ctx.font = font; ctx.fillText(text, 8, 32);
95
+ const data = ctx.getImageData(0, 0, 64, 64).data; let ink = 0; let hash = 0;
96
+ for (let i = 3; i < data.length; i += 4) { if (data[i]) ink += 1; hash = (hash * 31 + data[i]) | 0; }
97
+ return { ink, hash };
98
+ };
99
+ const glyphText = (el) => {
100
+ for (const pseudo of ['::before', '::after']) {
101
+ const style = getComputedStyle(el, pseudo);
102
+ const content = style.content;
103
+ if (content && content !== 'none' && content !== 'normal' && content !== '""') {
104
+ return { text: content.replace(/^["']|["']$/g, ''), style, pseudo };
105
+ }
106
+ }
107
+ const text = (el.textContent || '').trim();
108
+ return text ? { text, style: getComputedStyle(el), pseudo: null } : null;
109
+ };
110
+ const run = () => {
111
+ const icons = []; const seen = new Set();
112
+ for (const el of Array.from(document.querySelectorAll(config.selector))) {
113
+ const name = nameOf(el);
114
+ if (seen.has(name) || icons.length >= config.maxIcons) continue;
115
+ seen.add(name);
116
+ const rect = el.getBoundingClientRect();
117
+ const hidden = hiddenBy(el);
118
+ const label = accessibleName(el);
119
+ const record = { name, tag: el.tagName.toLowerCase(), width: Math.round(rect.width * 100) / 100, height: Math.round(rect.height * 100) / 100, hiddenBy: hidden, accessibleName: label.name, decorative: label.decorative };
120
+ if (el.tagName.toLowerCase() === 'svg' || el.querySelector('svg')) {
121
+ const svg = el.tagName.toLowerCase() === 'svg' ? el : el.querySelector('svg');
122
+ const box = svg.getBoundingClientRect();
123
+ record.kind = 'svg';
124
+ record.glyphPainted = box.width > 0 && box.height > 0 && !!svg.querySelector('path,circle,rect,line,polyline,polygon,use,ellipse');
125
+ record.fontLoaded = null; record.fontFamily = null;
126
+ } else if (el.tagName.toLowerCase() === 'img') {
127
+ record.kind = 'img';
128
+ record.glyphPainted = el.complete && el.naturalWidth > 0;
129
+ record.fontLoaded = null; record.fontFamily = null;
130
+ } else {
131
+ const glyph = glyphText(el);
132
+ record.kind = 'font';
133
+ if (!glyph) {
134
+ record.fontFamily = norm(getComputedStyle(el).fontFamily); record.fontLoaded = false; record.glyphPainted = false; record.hiddenBy = hidden || 'no glyph content';
135
+ } else {
136
+ const rawFamily = String(glyph.style.fontFamily || '').split(',')[0].trim().replace(/^['"]|['"]$/g, '');
137
+ const family = norm(rawFamily);
138
+ const weight = glyph.style.fontWeight || '400';
139
+ const faces = Array.from(document.fonts).filter((face) => norm(face.family) === family);
140
+ record.fontFamily = family;
141
+ record.fontLoaded = faces.some((face) => face.status === 'loaded');
142
+ record.fontStatus = faces.map((face) => face.status);
143
+ const icon = pixels(`${weight} 32px "${rawFamily}"`, glyph.text);
144
+ const fallback = pixels(`${weight} 32px "maggie-missing-font-probe"`, glyph.text);
145
+ record.glyphPainted = icon.ink > 0 && (icon.hash !== fallback.hash || icon.ink !== fallback.ink);
146
+ record.ink = icon.ink; record.fallbackInk = fallback.ink; record.glyphText = glyph.text.length <= 4 ? Array.from(glyph.text).map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ') : glyph.text.slice(0, 40);
147
+ if (glyph.pseudo && getComputedStyle(el, glyph.pseudo).display === 'none' && !record.hiddenBy) record.hiddenBy = glyph.pseudo + ' display:none';
148
+ }
149
+ const prefix = Object.keys(config.families).find((key) => (el.className || '').toString().split(/\s+/).some((token) => token === key || token.startsWith(key + '-')));
150
+ if (prefix) record.familyMatches = record.fontFamily === config.families[prefix].toLowerCase();
151
+ }
152
+ record.visible = !record.hiddenBy && record.width > 0 && record.height > 0;
153
+ icons.push(record);
154
+ }
155
+ out.textContent = JSON.stringify({ icons, fontsReady: document.fonts.status, faces: Array.from(document.fonts).map((face) => ({ family: norm(face.family), status: face.status })) });
156
+ document.body.appendChild(out);
157
+ };
158
+ const start = () => document.fonts.ready.then(() => setTimeout(run, config.settleMs));
159
+ if (document.readyState === 'complete') start(); else window.addEventListener('load', start);
160
+ })();
161
+ """
162
+
163
+
164
+ def _probe_script(selector: str, max_icons: int, families: dict[str, str], settle_ms: int) -> str:
165
+ config = {"reportId": REPORT_ID, "selector": selector, "maxIcons": max_icons, "families": families, "settleMs": settle_ms}
166
+ return "<script>" + PROBE.replace("__CONFIG__", json.dumps(config)) + "</script>"
167
+
168
+
169
+ def inject(document: str, probe: str) -> str:
170
+ index = document.lower().rfind("</body>")
171
+ return document[:index] + probe + document[index:] if index >= 0 else document + probe
172
+
173
+
174
+ class _ProbeServer:
175
+ """Serve a static build or proxy GET requests to a host, injecting the probe."""
176
+
177
+ def __init__(self, probe: str, base_url: str | None = None, dist: Path | None = None) -> None:
178
+ self.probe = probe; self.base_url = base_url.rstrip("/") if base_url else None; self.dist = dist.resolve() if dist else None
179
+ self.assets: list[dict] = []; self.lock = threading.Lock()
180
+ server = self
181
+
182
+ class Handler(BaseHTTPRequestHandler):
183
+ def log_message(self, *_args) -> None: # silence request logging
184
+ return
185
+
186
+ def do_GET(self) -> None: # noqa: N802 - stdlib handler name
187
+ status, content_type, body = server.fetch(self.path)
188
+ if status < 400 and "text/html" in content_type:
189
+ body = inject(body.decode("utf-8", errors="replace"), server.probe).encode("utf-8")
190
+ path = urlsplit(self.path).path
191
+ if path.lower().endswith(FONT_EXTENSIONS) or "font" in content_type:
192
+ with server.lock:
193
+ server.assets.append({"path": path, "status": status, "contentType": content_type.split(";")[0]})
194
+ self.send_response(status)
195
+ self.send_header("Content-Type", content_type)
196
+ self.send_header("Content-Length", str(len(body)))
197
+ self.end_headers()
198
+ self.wfile.write(body)
199
+
200
+ def do_POST(self) -> None: # noqa: N802 - writes are never forwarded
201
+ self.send_response(405); self.send_header("Content-Length", "0"); self.end_headers()
202
+
203
+ do_PUT = do_PATCH = do_DELETE = do_POST
204
+
205
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
206
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
207
+
208
+ def fetch(self, raw_path: str) -> tuple[int, str, bytes]:
209
+ path = urlsplit(raw_path).path or "/"
210
+ if self.dist is not None:
211
+ relative = path.lstrip("/")
212
+ target = (self.dist / relative).resolve()
213
+ if not target.is_relative_to(self.dist):
214
+ return 403, "text/plain", b"forbidden"
215
+ if target.is_dir():
216
+ target = target / "index.html"
217
+ elif not target.exists() and (self.dist / (relative + ".html")).exists():
218
+ target = self.dist / (relative + ".html")
219
+ if not target.is_file():
220
+ return 404, "text/plain", b"not found"
221
+ return 200, _content_type(target.name), target.read_bytes()
222
+ request = Request(self.base_url + raw_path, headers={"Accept-Encoding": "identity", "User-Agent": "maggie-icon-glyph-check/1"})
223
+ try:
224
+ with urlopen(request, timeout=30) as response:
225
+ return response.status, response.headers.get("Content-Type", "application/octet-stream"), response.read()
226
+ except HTTPError as error:
227
+ return error.code, error.headers.get("Content-Type", "text/plain") if error.headers else "text/plain", error.read() or b""
228
+ except URLError as error:
229
+ return 502, "text/plain", f"upstream unavailable: {error.reason}".encode()
230
+
231
+ def __enter__(self) -> "_ProbeServer":
232
+ self.thread.start(); return self
233
+
234
+ def __exit__(self, *_exc) -> None:
235
+ self.httpd.shutdown(); self.httpd.server_close()
236
+
237
+ @property
238
+ def origin(self) -> str:
239
+ return f"http://127.0.0.1:{self.httpd.server_port}"
240
+
241
+
242
+ def _content_type(name: str) -> str:
243
+ lower = name.lower()
244
+ for suffix, value in ((".html", "text/html; charset=utf-8"), (".css", "text/css"), (".js", "text/javascript"), (".mjs", "text/javascript"), (".svg", "image/svg+xml"), (".png", "image/png"), (".jpg", "image/jpeg"), (".webp", "image/webp"), (".json", "application/json"), (".woff2", "font/woff2"), (".woff", "font/woff"), (".ttf", "font/ttf"), (".otf", "font/otf")):
245
+ if lower.endswith(suffix):
246
+ return value
247
+ return "application/octet-stream"
248
+
249
+
250
+ def find_chrome() -> str:
251
+ chrome = shutil.which("google-chrome") or shutil.which("chromium") or shutil.which("chromium-browser") or shutil.which("chrome")
252
+ if not chrome:
253
+ raise RuntimeError("google-chrome or chromium is required for icon-glyph-check")
254
+ return chrome
255
+
256
+
257
+ def render(chrome: str, url: str, width: int, height: int, budget_ms: int) -> dict:
258
+ proc = subprocess.run([chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", f"--window-size={width},{height}", f"--virtual-time-budget={budget_ms}", "--dump-dom", url], capture_output=True, text=True, timeout=180, check=False)
259
+ if proc.returncode != 0:
260
+ raise RuntimeError(f"headless chrome failed: {(proc.stderr or '')[-300:]}")
261
+ match = re.search(rf'<pre id="{REPORT_ID}"[^>]*>(.*?)</pre>', proc.stdout, re.S)
262
+ if not match:
263
+ raise RuntimeError("icon probe did not report; the page may block inline scripts or never finished loading")
264
+ return json.loads(html_lib.unescape(match.group(1)) or "{}")
265
+
266
+
267
+ def _asset_status(icon: dict, assets: list[dict]) -> int:
268
+ if icon.get("kind") != "font":
269
+ return 200 if icon.get("glyphPainted") else 0
270
+ if icon.get("fontLoaded"):
271
+ return 200 # the face reached status "loaded", so its file was served
272
+ failing = [item["status"] for item in assets if item["status"] >= 400]
273
+ return min(failing) if failing else 0
274
+
275
+
276
+ def collect(routes: list[str], viewports: dict[str, tuple[int, int]], base_url: str | None, dist: Path | None, selector: str, families: dict[str, str], max_icons: int, budget_ms: int, settle_ms: int) -> dict:
277
+ if bool(base_url) == bool(dist):
278
+ raise ValueError("exactly one of --base-url or --dist is required")
279
+ chrome = find_chrome()
280
+ probe = _probe_script(selector, max_icons, families, settle_ms)
281
+ icons: list[dict] = []; errors: list[str] = []; route_results: list[dict] = []
282
+ with _ProbeServer(probe, base_url=base_url, dist=dist) as server:
283
+ for route in routes:
284
+ if not route.startswith("/"):
285
+ raise ValueError(f"route must start with /: {route}")
286
+ for viewport, (width, height) in viewports.items():
287
+ before = len(server.assets)
288
+ try:
289
+ report = render(chrome, server.origin + route, width, height, budget_ms)
290
+ except RuntimeError as error:
291
+ errors.append(f"{route} @ {viewport}: {error}")
292
+ route_results.append({"route": route, "viewport": viewport, "status": "error"})
293
+ continue
294
+ assets = server.assets[before:] or server.assets
295
+ found = report.get("icons") or []
296
+ for icon in found:
297
+ icon.update({"route": route, "viewport": viewport, "assetStatus": _asset_status(icon, assets)})
298
+ icons.append(icon)
299
+ route_results.append({"route": route, "viewport": viewport, "status": "rendered", "iconCount": len(found), "fontsReady": report.get("fontsReady")})
300
+ if not found:
301
+ errors.append(f"{route} @ {viewport}: no icons matched the selector")
302
+ font_assets = sorted({(item["path"], item["status"]) for item in server.assets})
303
+ failures = sorted({f"{icon['name']} @ {icon['route']} {icon['viewport']}" for icon in icons if icon_failure(icon)})
304
+ if failures:
305
+ errors.append("icons failed rendered-glyph checks: " + ", ".join(failures[:20]))
306
+ return {
307
+ "schemaVersion": SCHEMA, "collector": COLLECTOR, "passed": not errors,
308
+ "target": "dist" if dist else "base-url", "routes": routes, "viewports": {name: list(size) for name, size in viewports.items()},
309
+ "renders": route_results, "icons": icons,
310
+ "fontAssets": [{"path": path, "status": status} for path, status in font_assets],
311
+ "errors": errors, "generatedAt": datetime.now(timezone.utc).isoformat(), "mutation": "not executed",
312
+ }
313
+
314
+
315
+ def icon_failure(icon: dict) -> str | None:
316
+ """Return why one collected icon fails, or ``None`` when it is proven painted."""
317
+ if icon.get("hiddenBy"):
318
+ return f"hidden by {icon['hiddenBy']}"
319
+ if not (isinstance(icon.get("width"), (int, float)) and icon["width"] > 0 and isinstance(icon.get("height"), (int, float)) and icon["height"] > 0):
320
+ return "zero size"
321
+ if icon.get("kind") == "font" and icon.get("fontLoaded") is not True:
322
+ return "icon font not loaded"
323
+ if icon.get("glyphPainted") is not True:
324
+ return "glyph not painted (fallback font or blank)"
325
+ if icon.get("familyMatches") is False:
326
+ return "unexpected font family"
327
+ if not str(icon.get("accessibleName") or "").strip():
328
+ return "no accessible name"
329
+ return None
330
+
331
+
332
+ def _parse_viewports(values: list[str]) -> dict[str, tuple[int, int]]:
333
+ if not values:
334
+ return dict(DEFAULT_VIEWPORTS)
335
+ result: dict[str, tuple[int, int]] = {}
336
+ for value in values:
337
+ match = re.fullmatch(r"(?:([a-z0-9-]+)=)?(\d{3,4})x(\d{3,4})", value.strip(), re.I)
338
+ if not match:
339
+ raise ValueError(f"viewport must look like mobile=390x844 or 390x844: {value}")
340
+ name = match.group(1) or f"{match.group(2)}w"
341
+ result[name] = (int(match.group(2)), int(match.group(3)))
342
+ return result
343
+
344
+
345
+ def _parse_families(values: list[str]) -> dict[str, str]:
346
+ result = {}
347
+ for value in values:
348
+ prefix, sep, family = value.partition("=")
349
+ if not sep or not prefix.strip() or not family.strip():
350
+ raise ValueError(f"--family must look like ph=Phosphor: {value}")
351
+ result[prefix.strip()] = family.strip()
352
+ return result
353
+
354
+
355
+ def main(argv: list[str] | None = None) -> int:
356
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
357
+ parser.add_argument("--base-url", help="running host origin, e.g. http://localhost:4321 (GET-only proxy)")
358
+ parser.add_argument("--dist", type=Path, help="static build directory to serve locally")
359
+ parser.add_argument("--route", action="append", default=[], help="route to render; repeatable (default /)")
360
+ parser.add_argument("--viewport", action="append", default=[], help="name=WIDTHxHEIGHT; repeatable (default mobile, tablet, desktop)")
361
+ parser.add_argument("--selector", default=DEFAULT_SELECTOR, help="CSS selector for icon elements")
362
+ parser.add_argument("--family", action="append", default=[], help="expected font family per class prefix, e.g. ph=Phosphor")
363
+ parser.add_argument("--max-icons", type=int, default=60, help="representative icons per render (one per name)")
364
+ parser.add_argument("--budget-ms", type=int, default=8000)
365
+ parser.add_argument("--settle-ms", type=int, default=250)
366
+ parser.add_argument("--output", type=Path, required=True)
367
+ args = parser.parse_args(argv)
368
+ try:
369
+ report = collect(args.route or ["/"], _parse_viewports(args.viewport), args.base_url, args.dist, args.selector, _parse_families(args.family), args.max_icons, args.budget_ms, args.settle_ms)
370
+ except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error:
371
+ print(f"BLOCKED: maggie-design icon-glyph-check: {error}", file=sys.stderr)
372
+ return 2
373
+ args.output.parent.mkdir(parents=True, exist_ok=True)
374
+ args.output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
375
+ summary = {key: report[key] for key in ("schemaVersion", "collector", "passed", "routes", "errors")}
376
+ summary.update({"iconCount": len(report["icons"]), "output": str(args.output)})
377
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
378
+ return 0 if report["passed"] else 1
379
+
380
+
381
+ if __name__ == "__main__":
382
+ raise SystemExit(main())
@@ -5,9 +5,12 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import json
8
+ import sys
8
9
  from pathlib import Path
9
10
  from typing import Any
10
11
 
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+
11
14
 
12
15
  def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
13
16
  errors: list[str] = []
@@ -40,6 +43,27 @@ def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
40
43
  invalid.append(name)
41
44
  if invalid:
42
45
  errors.append("browser icon evidence has invisible, zero-size, inaccessible, or unserved glyphs")
46
+ # Evidence from `maggie design icon-glyph-check` also proves the icon font
47
+ # loaded and the glyph differs from the fallback font at every viewport.
48
+ # Hand-written legacy evidence cannot prove that, so it is only warned.
49
+ warnings: list[str] = []
50
+ glyph_proof = browser_report.get("collector") == "maggie-icon-glyph-check" or any(isinstance(icon, dict) and "glyphPainted" in icon for icon in icons)
51
+ glyph_failures: list[str] = []
52
+ if glyph_proof:
53
+ from maggie_icon_glyph_check import icon_failure
54
+
55
+ for icon in icons:
56
+ reason = icon_failure(icon) if isinstance(icon, dict) else "invalid icon record"
57
+ if reason:
58
+ glyph_failures.append(f"{icon.get('name', '?') if isinstance(icon, dict) else '?'} @ {icon.get('viewport', '?') if isinstance(icon, dict) else '?'}: {reason}")
59
+ expected_viewports = set((browser_report.get("viewports") or {}).keys()) if isinstance(browser_report.get("viewports"), dict) else set()
60
+ rendered = {icon.get("viewport") for icon in icons if isinstance(icon, dict)}
61
+ if expected_viewports and expected_viewports - rendered:
62
+ glyph_failures.append("no icon evidence for viewport(s): " + ", ".join(sorted(expected_viewports - rendered)))
63
+ if glyph_failures:
64
+ errors.append("rendered glyph proof failed: font not loaded, fallback glyph, hidden by CSS, or missing viewport")
65
+ else:
66
+ warnings.append("browser evidence does not prove font loading or glyph identity; collect it with `maggie design icon-glyph-check`")
43
67
  return {
44
68
  "schemaVersion": "maggie-icon-release-gate.v1",
45
69
  "passed": not errors,
@@ -47,9 +71,12 @@ def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
47
71
  "checks": {
48
72
  "sourceRuntime": source_ok,
49
73
  "servedAssets": browser_ok and not bool(invalid),
50
- "paintedGlyphs": bool(icons) and not bool(invalid),
74
+ "paintedGlyphs": bool(icons) and not bool(invalid) and not glyph_failures,
51
75
  "accessibility": bool(icons) and not bool(invalid),
52
76
  },
77
+ "glyphIdentityProven": glyph_proof and not glyph_failures,
78
+ "glyphFailures": glyph_failures[:50],
79
+ "warnings": warnings,
53
80
  "iconCount": len(icons),
54
81
  "invalidIcons": sorted(set(invalid)),
55
82
  "mutation": "not executed",
@@ -15,6 +15,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
15
15
  from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_operation_quality, validate_translation
16
16
  from localization_runner import generate, process_adapter
17
17
 
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ import maggie_email_localization as email_localization
20
+
18
21
 
19
22
  def generate_job(args: argparse.Namespace) -> int:
20
23
  if not args.confirm:
@@ -395,6 +398,7 @@ def main() -> int:
395
398
  publish_parser.add_argument("job", type=Path); publish_parser.add_argument("--confirm", action="store_true")
396
399
  for name in ("stale", "glossary"):
397
400
  command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
401
+ email_localization.add_parsers(sub)
398
402
  args = parser.parse_args()
399
403
  try:
400
404
  if args.command == "generate": return generate_job(args)
@@ -406,6 +410,7 @@ def main() -> int:
406
410
  if args.command == "review": return review(args.job, args.reviewer, args.decision)
407
411
  if args.command == "publish": return publish(args.job, args.confirm)
408
412
  if args.command == "stale": return stale_jobs(args.project)
413
+ if args.command in ("email-matrix", "lifecycle"): return email_localization.run(args)
409
414
  return glossary(args.project)
410
415
  except (OSError, ValueError, json.JSONDecodeError) as error:
411
416
  print(f"ERROR: {error}", file=sys.stderr); return 1