@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
@@ -10,6 +10,7 @@ import os
10
10
  import platform
11
11
  import re
12
12
  import secrets
13
+ import subprocess
13
14
  import sys
14
15
  from datetime import datetime, timezone
15
16
  from pathlib import Path
@@ -41,6 +42,14 @@ EVIDENCE_KINDS = {"test", "report", "screenshot", "command", "deployment", "issu
41
42
  RELATION_KINDS = {"related", "same-run", "same-fingerprint", "duplicates", "validates", "remediates", "caused-by"}
42
43
  ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
43
44
  REPO_ROOT = Path(__file__).resolve().parents[2]
45
+ FEEDBACK_TYPES = ("bug", "feature", "quality", "improvement", "general")
46
+ # The hosted endpoint accepts these types. Local-only types are submitted as a
47
+ # hosted type with the original kept in `subtype`, so no endpoint change is needed.
48
+ HOSTED_TYPES = {"bug", "feature", "quality", "general"}
49
+ HOSTED_TYPE_FALLBACK = {"improvement": "feature"}
50
+ REVIEW_MARKER = "REVIEW:"
51
+ FIX_SUBJECT_RE = re.compile(r"^(?:fix|bugfix|hotfix)(?:\([^)]*\))?!?:\s*|^(?:fix|fixes|fixed)\b\s*", re.IGNORECASE)
52
+ FEATURE_SUBJECT_RE = re.compile(r"^(?:feat|perf|refactor)(?:\([^)]*\))?!?:\s*", re.IGNORECASE)
44
53
 
45
54
 
46
55
  def now() -> str:
@@ -230,6 +239,8 @@ def collect(args: argparse.Namespace) -> int:
230
239
  # Persist one canonical representation so batch review and the hosted
231
240
  # API do not have to guess which indexing convention was used.
232
241
  batch_index -= batch_base
242
+ if args.type not in FEEDBACK_TYPES:
243
+ raise ValueError(f"type must be one of: {', '.join(FEEDBACK_TYPES)}")
233
244
  priority = safe_text(getattr(args, "priority", "") or "normal").lower()
234
245
  if priority not in {"low", "normal", "high", "critical"}:
235
246
  raise ValueError("priority must be low, normal, high, or critical")
@@ -321,6 +332,16 @@ def preview(args: argparse.Namespace) -> int:
321
332
  return 0
322
333
 
323
334
 
335
+ def hosted_payload(data: dict) -> dict:
336
+ """Map local-only feedback types onto the hosted contract without losing them."""
337
+ payload = dict(data)
338
+ local_type = payload.get("type")
339
+ if local_type not in HOSTED_TYPES:
340
+ payload["type"] = HOSTED_TYPE_FALLBACK.get(local_type, "general")
341
+ payload["subtype"] = local_type
342
+ return payload
343
+
344
+
324
345
  def submit(args: argparse.Namespace) -> int:
325
346
  if not args.confirm:
326
347
  raise ValueError("submit requires --confirm; feedback is never sent implicitly")
@@ -330,7 +351,7 @@ def submit(args: argparse.Namespace) -> int:
330
351
  parsed = urlparse(endpoint)
331
352
  if parsed.scheme != "https" or not parsed.netloc:
332
353
  raise ValueError("endpoint must be an absolute HTTPS URL")
333
- payload = dict(data)
354
+ payload = hosted_payload(data)
334
355
  payload["projectContext"] = data.get("projectContext") if data.get("privacy", {}).get("projectContextAllowed") else None
335
356
  request = Request(endpoint, data=json.dumps(payload, ensure_ascii=False).encode(), headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
336
357
  try:
@@ -352,7 +373,7 @@ def list_feedback(args: argparse.Namespace) -> int:
352
373
  for path in sorted(directory.glob("*.json")):
353
374
  try:
354
375
  data = read_feedback(path)
355
- records.append({"feedbackId": data.get("feedbackId"), "skill": data.get("skill"), "status": data.get("submission", {}).get("status", "draft"), "path": str(path)})
376
+ records.append({"feedbackId": data.get("feedbackId"), "skill": data.get("skill"), "type": data.get("type"), "status": data.get("submission", {}).get("status", "draft"), "path": str(path)})
356
377
  except (OSError, ValueError):
357
378
  continue
358
379
  print(json.dumps(records, indent=2, ensure_ascii=False))
@@ -361,7 +382,14 @@ def list_feedback(args: argparse.Namespace) -> int:
361
382
 
362
383
  def collect_batch(args: argparse.Namespace) -> int:
363
384
  """Create one privacy-safe draft per item while retaining shared batch context."""
364
- manifest = load_json(Path(args.batch_file).expanduser().resolve())
385
+ if args.batch_file == "-":
386
+ # Reading JSON from stdin lets a quoted heredoc carry multiline text and
387
+ # backticks without shell interpolation.
388
+ manifest = json.loads(sys.stdin.read())
389
+ if not isinstance(manifest, dict):
390
+ raise ValueError("batch input must be a JSON object")
391
+ else:
392
+ manifest = load_json(Path(args.batch_file).expanduser().resolve())
365
393
  batch_id = safe_text(manifest.get("batchId"))
366
394
  if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
367
395
  raise ValueError("batch file requires a safe batchId")
@@ -383,6 +411,9 @@ def collect_batch(args: argparse.Namespace) -> int:
383
411
  for index, item in enumerate(items):
384
412
  if not isinstance(item, dict):
385
413
  raise ValueError(f"items[{index}] must be an object")
414
+ unedited = [field for field in ("summary", "expected", "actual", "resolution", "validation") if REVIEW_MARKER in str(item.get(field) or "")]
415
+ if unedited:
416
+ raise ValueError(f"items[{index}] still has {REVIEW_MARKER} placeholders in: {', '.join(unedited)}; edit or remove the item before creating drafts")
386
417
  values = {
387
418
  "project": args.project,
388
419
  "run_report": None,
@@ -422,6 +453,75 @@ def collect_batch(args: argparse.Namespace) -> int:
422
453
  return 0
423
454
 
424
455
 
456
+ def session_summary(args: argparse.Namespace) -> int:
457
+ """Draft a reviewable batch file from commit subjects; nothing is collected or submitted."""
458
+ repo = Path(args.repo).expanduser().resolve()
459
+ since = str(args.since).strip()
460
+ if not since or since.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", since):
461
+ raise ValueError("--since must be a git revision such as a tag, branch, or commit")
462
+ batch_id = safe_text(args.batch_id) or f"session-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
463
+ if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
464
+ raise ValueError("batch ID is invalid")
465
+ completed = subprocess.run(
466
+ ["git", "-C", str(repo), "log", "--no-merges", "--format=%h%x1f%s", f"{since}..HEAD"],
467
+ capture_output=True, text=True, check=False,
468
+ )
469
+ if completed.returncode:
470
+ raise ValueError("git log failed; check --repo and --since")
471
+ items = []
472
+ skipped = 0
473
+ for line in completed.stdout.splitlines():
474
+ sha, _, subject = line.partition("\x1f")
475
+ subject = safe_text(subject)[:200]
476
+ if not sha or not subject:
477
+ continue
478
+ if FIX_SUBJECT_RE.match(subject):
479
+ kind, text = "bug", FIX_SUBJECT_RE.sub("", subject, count=1)
480
+ elif FEATURE_SUBJECT_RE.match(subject):
481
+ kind, text = "improvement", FEATURE_SUBJECT_RE.sub("", subject, count=1)
482
+ else:
483
+ skipped += 1
484
+ continue
485
+ text = text[:1].upper() + text[1:] if text else subject
486
+ items.append({
487
+ "type": kind,
488
+ "summary": text,
489
+ "expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
490
+ "actual": f"{REVIEW_MARKER} what happened before commit {sha}",
491
+ "fixed": True,
492
+ "resolution": text,
493
+ "validation": f"{REVIEW_MARKER} the test or check that verified commit {sha}",
494
+ "evidence": [f"commit:{sha}"],
495
+ })
496
+ if len(items) >= 49:
497
+ break
498
+ items.append({
499
+ "type": "feature",
500
+ "summary": f"{REVIEW_MARKER} describe an open ask from this session, or delete this item",
501
+ "expected": "",
502
+ "actual": "",
503
+ })
504
+ manifest = {
505
+ "batchId": batch_id,
506
+ "skill": safe_text(args.skill),
507
+ "runId": safe_text(args.run_id),
508
+ "phase": safe_text(args.phase) or "session-summary",
509
+ "reviewRequired": True,
510
+ "note": "Draft only. Remove items unrelated to Maggie skills or CLIs, replace every REVIEW: and delete the open-ask placeholder if unused. `maggie feedback batch` rejects unedited REVIEW: fields.",
511
+ "items": items,
512
+ }
513
+ project = Path(args.project).resolve()
514
+ output = Path(args.output) if args.output else project / ".maggie" / "feedback" / "batches" / f"{batch_id}-draft.json"
515
+ if not output.is_absolute():
516
+ output = project / output
517
+ if output.exists():
518
+ raise ValueError("output already exists; choose another --output or --batch-id")
519
+ output.parent.mkdir(parents=True, exist_ok=True)
520
+ output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
521
+ print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "skippedCommits": skipped, "output": str(output), "submitted": False}, ensure_ascii=False))
522
+ return 0
523
+
524
+
425
525
  def batch_review(args: argparse.Namespace) -> int:
426
526
  """Aggregate one bounded batch without exposing raw payloads or paths."""
427
527
  project = Path(args.project).resolve()
@@ -534,7 +634,7 @@ def main() -> int:
534
634
  collect_parser.add_argument("--skill", default="")
535
635
  collect_parser.add_argument("--run-id", default="")
536
636
  collect_parser.add_argument("--run-report")
537
- collect_parser.add_argument("--type", choices=("bug", "feature", "quality", "general"), default="bug")
637
+ collect_parser.add_argument("--type", choices=FEEDBACK_TYPES, default="bug", help="improvement: a proposal to improve a skill or CLI (submitted as feature with subtype)")
538
638
  collect_parser.add_argument("--phase", default="")
539
639
  collect_parser.add_argument("--summary", required=True)
540
640
  collect_parser.add_argument("--expected", default="")
@@ -562,7 +662,16 @@ def main() -> int:
562
662
  collect_parser.add_argument("--relationship", dest="relationships", action="append", default=[], help="safe relation=target pointer")
563
663
  batch_parser = sub.add_parser("batch")
564
664
  batch_parser.add_argument("--project", default=argparse.SUPPRESS)
565
- batch_parser.add_argument("--batch-file", required=True)
665
+ batch_parser.add_argument("--batch-file", required=True, help="batch JSON file, or - to read JSON from stdin")
666
+ summary_parser = sub.add_parser("session-summary", help="draft batch items from git commit subjects for review")
667
+ summary_parser.add_argument("--project", default=argparse.SUPPRESS)
668
+ summary_parser.add_argument("--since", required=True, help="git revision; commits after it are summarized")
669
+ summary_parser.add_argument("--repo", default=".", help="git repository to read (default: current directory)")
670
+ summary_parser.add_argument("--batch-id", default="")
671
+ summary_parser.add_argument("--skill", default="")
672
+ summary_parser.add_argument("--run-id", default="")
673
+ summary_parser.add_argument("--phase", default="")
674
+ summary_parser.add_argument("--output")
566
675
  review_parser = sub.add_parser("batch-review")
567
676
  review_parser.add_argument("--project", default=argparse.SUPPRESS)
568
677
  review_parser.add_argument("--batch-id", required=True)
@@ -581,6 +690,7 @@ def main() -> int:
581
690
  if args.command == "collect": return collect(args)
582
691
  if args.command == "batch": return collect_batch(args)
583
692
  if args.command == "batch-review": return batch_review(args)
693
+ if args.command == "session-summary": return session_summary(args)
584
694
  if args.command == "preview": return preview(args)
585
695
  if args.command == "submit": return submit(args)
586
696
  return list_feedback(args)
@@ -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",