@topy-ai/maggie 0.7.53 → 0.7.54

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 (42) hide show
  1. package/README-zh-TW.md +28 -50
  2. package/README.md +85 -64
  3. package/bin/maggie.js +51 -7
  4. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  5. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  6. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  7. package/bundled-skills/maggie-booking/SKILL.md +20 -12
  8. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  9. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  10. package/bundled-skills/maggie-deployment/SKILL.md +27 -0
  11. package/bundled-skills/maggie-design/SKILL.md +81 -0
  12. package/bundled-skills/maggie-feedback/SKILL.md +10 -2
  13. package/bundled-skills/maggie-marketplace/SKILL.md +1 -1
  14. package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
  15. package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
  16. package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
  17. package/bundled-skills/maggie-social-share/SKILL.md +30 -0
  18. package/bundled-tools/clis/maggie_booking.py +9 -8
  19. package/bundled-tools/clis/maggie_dash.py +173 -3
  20. package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
  21. package/bundled-tools/clis/maggie_design.py +7 -1
  22. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  23. package/bundled-tools/clis/maggie_design_lint.py +131 -1
  24. package/bundled-tools/clis/maggie_feedback.py +41 -13
  25. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  26. package/bundled-tools/clis/maggie_localization.py +3 -0
  27. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  28. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  29. package/bundled-tools/clis/maggie_mobile.py +84 -1
  30. package/bundled-tools/clis/maggie_product_release.py +49 -1
  31. package/bundled-tools/clis/maggie_social_share.py +122 -1
  32. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  33. package/package.json +1 -1
  34. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  35. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  36. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  37. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  38. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  39. package/bundled-references/maggiedash-booking/README.md +0 -288
  40. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  41. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  42. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -20,6 +20,11 @@
20
20
  Building with ``sudo`` leaves root-owned ``dist``/cache files that the
21
21
  unprivileged prune cannot remove; the release refuses that and names the
22
22
  one-time fix.
23
+ - ``preview-auth-check``: before (or after) putting basic auth on a preview
24
+ host, find every workflow, script or webhook config that still sends
25
+ requests to it. Each must be repointed to production or carry credentials;
26
+ otherwise it fails on every push (a skills sync kept posting to a protected
27
+ preview for days and left a public page two versions behind).
23
28
 
24
29
  Values of secrets are never printed; only variable names and digests.
25
30
  """
@@ -385,6 +390,56 @@ def vps_release(args: argparse.Namespace, ssh_args: list[str]) -> dict:
385
390
  return plan
386
391
 
387
392
 
393
+ SCAN_SUFFIXES = {".yml", ".yaml", ".json", ".jsonc", ".toml", ".sh", ".mjs", ".js", ".cjs", ".ts", ".py", ".env", ".example"}
394
+ SCAN_SKIP = {"node_modules", ".git", "dist", "build", ".next", ".astro", ".wrangler", ".turbo", ".maggie", "coverage"}
395
+ SENDS = re.compile(r"\b(?:curl|wget|fetch|axios|requests\.(?:post|put|patch|get)|httpx|Invoke-WebRequest|http\.(?:post|request))\b|-X\s*(?:POST|PUT|PATCH)|\bmethod\s*[:=]\s*[\"']?(?:POST|PUT|PATCH)\b|\b(?:webhook|callback|notify)_?url\b", re.I)
396
+ # Only basic-auth credentials get through a basic-auth gate; a bearer token does not.
397
+ BASIC_CREDENTIALS = re.compile(r"(?:\s-u\s|--user\b|authorization\s*[:=]\s*[\"']?basic\b|\bbasic\s+\$|PREVIEW_(?:USER|PASS|PASSWORD|AUTH|BASIC)|BASIC_AUTH|https?://\$\{?\w+\}?:\$)", re.I)
398
+ ASSIGN = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*[:=]")
399
+
400
+
401
+ def preview_auth_check(repo: Path, host: str, live: bool) -> dict:
402
+ """Workflows and scripts that send requests to a basic-auth protected preview host."""
403
+ host = host.lower().removeprefix("https://").removeprefix("http://").strip("/")
404
+ pattern = re.compile(rf"https?://{re.escape(host)}(?:[/:\s\"'`]|$)", re.I)
405
+ findings = []
406
+ for path in sorted(repo.rglob("*")):
407
+ if not path.is_file() or any(part in SCAN_SKIP for part in path.relative_to(repo).parts) or (path.suffix not in SCAN_SUFFIXES and path.name != "Dockerfile"):
408
+ continue
409
+ try:
410
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
411
+ except OSError:
412
+ continue
413
+ for number, line in enumerate(lines, 1):
414
+ if not pattern.search(line):
415
+ continue
416
+ window = "\n".join(lines[max(0, number - 6):number + 5])
417
+ uses = [number]
418
+ assigned = ASSIGN.match(line)
419
+ if assigned:
420
+ # The host is put in a variable (SITE: https://preview...); follow the variable.
421
+ name = re.escape(assigned.group(1))
422
+ uses = [index for index, other in enumerate(lines, 1) if index != number and re.search(rf"\$\{{?{name}\b|\benv\.{name}\b|process\.env\.{name}\b|os\.environ\S*{name}", other)]
423
+ window = "\n".join("\n".join(lines[max(0, index - 4):index + 3]) for index in uses)
424
+ if not SENDS.search(window):
425
+ continue
426
+ authed = bool(BASIC_CREDENTIALS.search(window))
427
+ findings.append({"file": str(path.relative_to(repo)), "line": number, "requestLines": uses[:5], "credentials": authed, "severity": "ok" if authed else "error",
428
+ "fix": None if authed else "repoint this to production, or send the preview's basic-auth credentials from a secret (a bearer token does not pass basic auth)"})
429
+ probe = None
430
+ if live:
431
+ try:
432
+ with urlopen(Request(f"https://{host}/", method="GET", headers={"User-Agent": "MaggieDeployment/1.0"}), timeout=15) as response:
433
+ probe = {"status": response.status, "basicAuth": False}
434
+ except HTTPError as error:
435
+ probe = {"status": error.code, "basicAuth": error.code == 401 and "basic" in (error.headers.get("WWW-Authenticate") or "").lower()}
436
+ except (URLError, TimeoutError, OSError) as error:
437
+ probe = {"status": None, "error": type(error).__name__}
438
+ errors = [item for item in findings if item["severity"] == "error"]
439
+ return {"schemaVersion": "maggie-deployment-preview-auth.v1", "host": host, "passed": not errors, "senders": findings, "unauthenticated": len(errors), "live": probe,
440
+ "note": "Every sender above must keep working once the preview asks for a password. Check the workflow runs after the change, and the public surfaces they feed."}
441
+
442
+
388
443
  def main(argv: list[str] | None = None) -> int:
389
444
  parser = argparse.ArgumentParser(prog="maggie deployment", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
390
445
  sub = parser.add_subparsers(dest="command", required=True)
@@ -425,7 +480,11 @@ def main(argv: list[str] | None = None) -> int:
425
480
  vr.add_argument("--keep", type=int, default=2)
426
481
  vr.add_argument("--timeout", type=int, default=1800)
427
482
  vr.add_argument("--confirm", action="store_true")
428
- for command in (cf, fp, wc, vr):
483
+ pa = sub.add_parser("preview-auth-check", help="find workflows/webhooks that send requests to a basic-auth protected preview host")
484
+ pa.add_argument("--host", required=True, help="the preview hostname, e.g. preview.example.com")
485
+ pa.add_argument("--repo", type=Path, action="append", default=[], help="repository to scan (repeatable; default .)")
486
+ pa.add_argument("--live", action="store_true", help="also request the host and report whether it asks for basic auth")
487
+ for command in (cf, fp, wc, vr, pa):
429
488
  command.add_argument("--output", type=Path)
430
489
  args = parser.parse_args(argv)
431
490
  ssh_args = []
@@ -440,6 +499,10 @@ def main(argv: list[str] | None = None) -> int:
440
499
  elif args.command == "file-parity":
441
500
  report = file_parity(args.host, args.root, args.path, Path(args.repo).resolve(), args.ref, args.subdir, ssh_args)
442
501
  failed = not report["passed"]
502
+ elif args.command == "preview-auth-check":
503
+ reports = [preview_auth_check(repo.resolve(), args.host, args.live) for repo in (args.repo or [Path(".")])]
504
+ report = reports[0] if len(reports) == 1 else {"passed": all(item["passed"] for item in reports), "repositories": reports}
505
+ failed = not report["passed"]
443
506
  elif args.command == "warm-check":
444
507
  report = warm_check(args.url, max(0, args.warm), max(1, args.samples), args.budget_ms)
445
508
  failed = not report["passed"]
@@ -1007,11 +1007,13 @@ commands:
1007
1007
  status show a design job and its per-step progress
1008
1008
  resume restart a failed design workflow
1009
1009
  author plan an original first-party page
1010
+ bulk many similar pages from one brief, facts file and validator
1010
1011
  markup-check validate optional style attributes, content keys, and entities
1011
1012
  pressable-check flag pressables/buttons nested in another pressable (JSX/TSX)
1012
1013
  layer-check flag unlayered CSS that overrides Tailwind v4 utilities
1013
1014
  safe-area-check flag SafeAreaView inside iOS modal routes / RN Modal
1014
1015
  textinput-check flag multiline TextInput without a height / grow-only auto-grow (RN Web)
1016
+ sheet-check flag a sheet close followed in the same tick by a toast/refresh/navigation (iOS freeze)
1015
1017
  palette generate or validate a semantic palette role contract
1016
1018
  verify run automatable design-job steps and record evidence
1017
1019
  icon-glyph-check render icons and prove font load and glyph paint
@@ -1019,7 +1021,7 @@ commands:
1019
1021
  reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
1020
1022
  """)
1021
1023
  return 0
1022
- if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check", "textinput-check"}:
1024
+ if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check", "textinput-check", "sheet-check"}:
1023
1025
  from maggie_design_lint import main as lint_main
1024
1026
  return lint_main(sys.argv[1:])
1025
1027
  if len(sys.argv) > 1 and sys.argv[1] == "verify":
@@ -1183,6 +1185,10 @@ commands:
1183
1185
  except (OSError, ValueError, json.JSONDecodeError) as error:
1184
1186
  print(f"BLOCKED: maggie-design section-validate: {error}", file=sys.stderr)
1185
1187
  return 1
1188
+ if len(sys.argv) > 1 and sys.argv[1] == "bulk":
1189
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
1190
+ from maggie_design_bulk import main as bulk_main
1191
+ return bulk_main(sys.argv[2:])
1186
1192
  if len(sys.argv) > 1 and sys.argv[1] == "author":
1187
1193
  author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
1188
1194
  author.add_argument("--project", type=Path, default=Path.cwd())
@@ -0,0 +1,290 @@
1
+ """Bulk author mode: many similar pages from one brief, facts file and validator.
2
+
3
+ One job tracks every target page through write -> validate -> picture -> seed.
4
+ The rules come from a run of 255 industry pages:
5
+
6
+ * a page gets its picture only after it validated and its file has not
7
+ changed since (a picture worker that rewrites a file while a writer still
8
+ edits it loses work);
9
+ * a writer receives the file's current hash and must re-read the page if it
10
+ changed before writing (``record --stage written --expect-hash``);
11
+ * changing the brief, facts or validator mid-run sends every page that passed
12
+ under the old rules back to ``revalidate``;
13
+ * pages are seeded only when every page in the job passed all checks.
14
+
15
+ The validator is the project's own command (JSON argv; the page path is
16
+ appended). Two built-in checks need no command: internal links must point at a
17
+ route of the job or ``--known-routes``, and every price must appear in the
18
+ facts file.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import hashlib
24
+ import json
25
+ import re
26
+ import subprocess
27
+ import sys
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ STAGES = ("planned", "written", "validated", "pictured", "seeded")
32
+ LINK = re.compile(r"""(?:href=["']|\]\(|"(?:href|url|link)"\s*:\s*")(/[^"')\s#?]*)""")
33
+ PRICE = re.compile(r"(?:[£$€¥]\s?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s?(?:GBP|USD|EUR)\b)")
34
+
35
+
36
+ def now() -> str:
37
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
38
+
39
+
40
+ def digest(path: Path) -> str | None:
41
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16] if path.is_file() else None
42
+
43
+
44
+ def rules_digest(job: dict, project: Path) -> str:
45
+ parts = [json.dumps(job["validator"]), json.dumps(job["checks"]), json.dumps(job["knownRoutes"])]
46
+ for key in ("brief", "facts"):
47
+ parts.append(digest(project / job[key]) or "missing" if job.get(key) else "")
48
+ return hashlib.sha256("\n".join(parts).encode()).hexdigest()[:16]
49
+
50
+
51
+ def job_path(project: Path, job_id: str) -> Path:
52
+ return project / ".maggie" / "design" / f"{job_id}.json"
53
+
54
+
55
+ def load(project: Path, job_id: str) -> dict:
56
+ return json.loads(job_path(project, job_id).read_text(encoding="utf-8"))
57
+
58
+
59
+ def save(project: Path, job: dict) -> None:
60
+ path = job_path(project, job["id"])
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ path.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
63
+
64
+
65
+ def refresh_rules(job: dict, project: Path) -> list[str]:
66
+ """When the rules changed, earlier passes no longer count."""
67
+ current = rules_digest(job, project)
68
+ if current == job.get("rulesDigest"):
69
+ return []
70
+ moved = []
71
+ for target in job["targets"]:
72
+ if STAGES.index(target["stage"]) >= STAGES.index("validated") and target["stage"] != "seeded":
73
+ target["stage"] = "written"
74
+ target["revalidate"] = True
75
+ moved.append(target["id"])
76
+ job["rulesDigest"] = current
77
+ job["history"].append({"at": now(), "event": "rules-changed", "revalidate": len(moved)})
78
+ return moved
79
+
80
+
81
+ def builtin_checks(job: dict, project: Path, text: str) -> list[str]:
82
+ errors = []
83
+ if "links" in job["checks"]:
84
+ routes = {target["route"].rstrip("/") or "/" for target in job["targets"]} | {route.rstrip("/") or "/" for route in job["knownRoutes"]}
85
+ for link in sorted(set(LINK.findall(text))):
86
+ if (link.rstrip("/") or "/") not in routes and not link.startswith(("/_", "/api/", "/images/", "/media/", "/fonts/")) and "." not in link.rsplit("/", 1)[-1]:
87
+ errors.append(f"link to {link}, which is not a page of this job or a known route")
88
+ if "prices" in job["checks"] and job.get("facts"):
89
+ facts = (project / job["facts"]).read_text(encoding="utf-8") if (project / job["facts"]).is_file() else ""
90
+ allowed = {re.sub(r"\s", "", value) for value in PRICE.findall(facts)}
91
+ for price in sorted({re.sub(r"\s", "", value) for value in PRICE.findall(text)}):
92
+ if price not in allowed:
93
+ errors.append(f"price {price} is not in the facts file")
94
+ return errors
95
+
96
+
97
+ def page_text(path: Path) -> str:
98
+ """The page's text; for a JSON page, its string values (escapes decoded)."""
99
+ raw = path.read_text(encoding="utf-8", errors="replace")
100
+ if path.suffix != ".json":
101
+ return raw
102
+ strings: list[str] = []
103
+
104
+ def walk(value: object) -> None:
105
+ if isinstance(value, str):
106
+ strings.append(value)
107
+ elif isinstance(value, dict):
108
+ for key, item in value.items():
109
+ # Keep "href": "/x" shapes visible to the link check.
110
+ if isinstance(item, str) and key in {"href", "url", "link"}:
111
+ strings.append(f'"{key}": "{item}"')
112
+ else:
113
+ walk(item)
114
+ elif isinstance(value, list):
115
+ for item in value:
116
+ walk(item)
117
+
118
+ try:
119
+ walk(json.loads(raw))
120
+ except json.JSONDecodeError:
121
+ return raw
122
+ return "\n".join(strings)
123
+
124
+
125
+ def validate_target(job: dict, project: Path, target: dict, timeout: int) -> dict:
126
+ path = project / target["file"]
127
+ if not path.is_file():
128
+ return {"passed": False, "errors": ["page file does not exist yet"]}
129
+ errors = builtin_checks(job, project, page_text(path))
130
+ if job["validator"]:
131
+ try:
132
+ completed = subprocess.run([*job["validator"], str(path)], cwd=project, capture_output=True, text=True, timeout=timeout, check=False)
133
+ except (OSError, subprocess.TimeoutExpired) as error:
134
+ return {"passed": False, "errors": errors + [f"validator could not run: {type(error).__name__}"]}
135
+ if completed.returncode:
136
+ detail = (completed.stdout.strip() or completed.stderr.strip()).splitlines()[-5:]
137
+ errors.extend(["validator: " + line[:300] for line in detail] or ["validator failed"])
138
+ return {"passed": not errors, "errors": errors}
139
+
140
+
141
+ def eligible(job: dict, project: Path, stage: str) -> list[dict]:
142
+ batch = []
143
+ for target in job["targets"]:
144
+ current = digest(project / target["file"])
145
+ if stage == "write" and target["stage"] in {"planned", "written"} and (target["stage"] == "planned" or target.get("revalidate") or target.get("lastErrors")):
146
+ batch.append({"id": target["id"], "route": target["route"], "file": target["file"], "currentHash": target.get("knownHash") or "none", "errors": target.get("lastErrors", [])})
147
+ elif stage == "picture" and target["stage"] == "validated" and current == target.get("validatedHash"):
148
+ # Stable since validation: nobody is editing it.
149
+ batch.append({"id": target["id"], "route": target["route"], "file": target["file"], "validatedHash": current})
150
+ if len(batch) >= job["batchSize"]:
151
+ break
152
+ return batch
153
+
154
+
155
+ def command(args: argparse.Namespace) -> int:
156
+ project = args.project.resolve()
157
+ if args.bulk_command == "plan":
158
+ if not args.confirm:
159
+ print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
160
+ return 2
161
+ raw = json.loads(args.targets.read_text(encoding="utf-8"))
162
+ items = raw.get("targets", raw) if isinstance(raw, dict) else raw
163
+ targets, seen = [], set()
164
+ for item in items:
165
+ route = str(item.get("route") or "")
166
+ if not route.startswith("/") or route == "/":
167
+ raise ValueError(f"target route must be a non-homepage path: {route!r}")
168
+ target_id = str(item.get("id") or route.strip("/").replace("/", "-"))
169
+ if target_id in seen:
170
+ raise ValueError(f"duplicate target id {target_id}")
171
+ seen.add(target_id)
172
+ targets.append({"id": target_id, "route": route, "file": str(item.get("file") or f"src/content/pages/{target_id}.json"), "stage": "planned"})
173
+ job_id = "bulk-" + hashlib.sha256((str(project) + "\n" + "\n".join(t["route"] for t in targets)).encode()).hexdigest()[:12]
174
+ relative = lambda path: str(path.resolve().relative_to(project)) if path else None # noqa: E731
175
+ job = {"schemaVersion": "maggie-design-bulk.v1", "id": job_id, "mode": "bulk-author", "brief": relative(args.brief), "facts": relative(args.facts),
176
+ "validator": json.loads(args.validator) if args.validator else [], "checks": sorted(set(args.check)), "knownRoutes": args.known_routes,
177
+ "batchSize": args.batch_size, "targets": targets, "history": [{"at": now(), "event": "planned", "targets": len(targets)}]}
178
+ if job["validator"] and not (isinstance(job["validator"], list) and all(isinstance(part, str) for part in job["validator"])):
179
+ raise ValueError("--validator must be a JSON argv array")
180
+ job["rulesDigest"] = rules_digest(job, project)
181
+ save(project, job)
182
+ print(json.dumps({"job": job_id, "targets": len(targets), "batchSize": job["batchSize"], "plan": str(job_path(project, job_id))}, indent=2))
183
+ return 0
184
+ job = load(project, args.job)
185
+ revalidate = refresh_rules(job, project)
186
+ if args.bulk_command == "next":
187
+ batch = eligible(job, project, args.stage)
188
+ save(project, job)
189
+ print(json.dumps({"job": job["id"], "stage": args.stage, "revalidate": revalidate, "batch": batch, "rule": "re-read each file and compare currentHash before writing" if args.stage == "write" else "draw only these pages; skip any whose hash changed"}, indent=2, ensure_ascii=False))
190
+ return 0
191
+ if args.bulk_command == "record":
192
+ target = next((item for item in job["targets"] if item["id"] == args.target), None)
193
+ if not target:
194
+ raise ValueError(f"unknown target {args.target}")
195
+ current = digest(project / target["file"])
196
+ if args.stage == "written":
197
+ # knownHash is the file as the job last saw it (written, validated or
198
+ # pictured). A writer who read another version must re-read first.
199
+ if args.expect_hash is None:
200
+ raise ValueError("--expect-hash is required: pass the currentHash from `next --stage write` ('none' for a new page)")
201
+ if args.expect_hash != (target.get("knownHash") or "none"):
202
+ raise ValueError(f"{target['id']} changed since you read it (now {target.get('knownHash')}); re-read the page, apply your edit again and record with the new hash")
203
+ target.update({"stage": "written", "knownHash": current, "revalidate": False})
204
+ elif args.stage == "pictured":
205
+ if target["stage"] != "validated":
206
+ raise ValueError(f"{target['id']} is not validated; validate it before drawing")
207
+ target.update({"stage": "pictured", "pictureHash": current, "knownHash": current})
208
+ target["updatedAt"] = now()
209
+ save(project, job)
210
+ print(json.dumps({"target": target["id"], "stage": target["stage"], "hash": current}, indent=2))
211
+ return 0
212
+ if args.bulk_command == "validate":
213
+ results = []
214
+ for target in job["targets"]:
215
+ if args.only and target["id"] not in args.only:
216
+ continue
217
+ if target["stage"] == "planned" or (target["stage"] in {"validated", "pictured", "seeded"} and digest(project / target["file"]) == target.get("validatedHash") and not target.get("revalidate")):
218
+ continue
219
+ outcome = validate_target(job, project, target, args.timeout)
220
+ current = digest(project / target["file"])
221
+ if outcome["passed"]:
222
+ # A page that changed after its picture needs the picture again.
223
+ target.update({"stage": "validated" if target.get("pictureHash") != current else "pictured", "validatedHash": current, "knownHash": current, "revalidate": False, "lastErrors": []})
224
+ else:
225
+ target.update({"stage": "written", "lastErrors": outcome["errors"][:20]})
226
+ results.append({"id": target["id"], **outcome})
227
+ save(project, job)
228
+ failed = [item for item in results if not item["passed"]]
229
+ print(json.dumps({"job": job["id"], "validated": len(results) - len(failed), "failed": failed[:50], "revalidate": revalidate}, indent=2, ensure_ascii=False))
230
+ return 0 if not failed else 1
231
+ # status / seed
232
+ counts = {stage: sum(1 for target in job["targets"] if target["stage"] == stage) for stage in STAGES}
233
+ stale = [target["id"] for target in job["targets"] if target["stage"] in {"validated", "pictured"} and digest(project / target["file"]) != target.get("validatedHash")]
234
+ ready = counts["pictured"] + counts["seeded"] == len(job["targets"]) and not stale
235
+ if args.bulk_command == "seed":
236
+ if not ready:
237
+ print(json.dumps({"job": job["id"], "seed": "blocked", "counts": counts, "changedAfterValidation": stale, "reason": "every page must be validated, pictured and unchanged since validation before any page is seeded"}, indent=2))
238
+ return 1
239
+ if not args.confirm:
240
+ print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
241
+ return 2
242
+ for target in job["targets"]:
243
+ target["stage"] = "seeded"
244
+ job["history"].append({"at": now(), "event": "seeded", "targets": len(job["targets"])})
245
+ save(project, job)
246
+ print(json.dumps({"job": job["id"], "seed": "ready", "targets": [{"route": t["route"], "file": t["file"]} for t in job["targets"]]}, indent=2))
247
+ return 0
248
+ save(project, job)
249
+ print(json.dumps({"job": job["id"], "counts": counts, "changedAfterValidation": stale, "revalidate": revalidate, "seedReady": ready}, indent=2))
250
+ return 0
251
+
252
+
253
+ def add_parser(argv: list[str]) -> argparse.Namespace:
254
+ parser = argparse.ArgumentParser(prog="maggie design bulk", description=__doc__.splitlines()[0])
255
+ sub = parser.add_subparsers(dest="bulk_command", required=True)
256
+ plan = sub.add_parser("plan", help="create a bulk author job from a target list")
257
+ plan.add_argument("--targets", type=Path, required=True, help='JSON [{"id"?, "route", "file"?}] or {"targets": [...]}')
258
+ plan.add_argument("--brief", type=Path)
259
+ plan.add_argument("--facts", type=Path)
260
+ plan.add_argument("--validator", help="JSON argv; the page file path is appended")
261
+ plan.add_argument("--check", action="append", choices=["links", "prices"], default=[])
262
+ plan.add_argument("--known-routes", nargs="*", default=[])
263
+ plan.add_argument("--batch-size", type=int, default=8)
264
+ plan.add_argument("--confirm", action="store_true")
265
+ for name in ("next", "record", "validate", "status", "seed"):
266
+ command_parser = sub.add_parser(name)
267
+ command_parser.add_argument("job")
268
+ if name == "next":
269
+ command_parser.add_argument("--stage", choices=["write", "picture"], required=True)
270
+ if name == "record":
271
+ command_parser.add_argument("--target", required=True)
272
+ command_parser.add_argument("--stage", choices=["written", "pictured"], required=True)
273
+ command_parser.add_argument("--expect-hash", help="the currentHash you read before writing ('none' for a new page)")
274
+ if name == "validate":
275
+ command_parser.add_argument("--only", nargs="*")
276
+ command_parser.add_argument("--timeout", type=int, default=120)
277
+ if name == "seed":
278
+ command_parser.add_argument("--confirm", action="store_true")
279
+ for action in sub.choices.values():
280
+ action.add_argument("--project", type=Path, default=Path.cwd())
281
+ return parser.parse_args(argv)
282
+
283
+
284
+ def main(argv: list[str]) -> int:
285
+ args = add_parser(argv)
286
+ try:
287
+ return command(args)
288
+ except (OSError, ValueError, json.JSONDecodeError) as error:
289
+ print(f"BLOCKED: maggie design bulk: {error}", file=sys.stderr)
290
+ return 1
@@ -22,6 +22,13 @@ Expo Router modal route (``presentation: "transparentModal"``, ``"modal"``,
22
22
  surfaces can measure a 0 top inset, so the header and Close button sit under
23
23
  the notch or Dynamic Island; web screenshots cannot show it. Read insets with
24
24
  ``useSafeAreaInsets()`` from the root ``SafeAreaProvider`` instead.
25
+
26
+ ``sheet-check`` flags closing a modal sheet and, in the same tick, showing a
27
+ toast, refreshing or writing query data, navigating, or opening another sheet.
28
+ On iOS the closing sheet's transparent window can stay on top of the app and
29
+ swallow every touch until the app is killed. Close first, then do the rest
30
+ once the sheet has slid away (``afterSheetCloses(fn)``: ``setTimeout`` of the
31
+ slide duration on iOS/Android, immediate on web).
25
32
  """
26
33
 
27
34
  from __future__ import annotations
@@ -378,6 +385,124 @@ def textinput_check(project: Path, paths: list[Path]) -> dict[str, object]:
378
385
  return {"schemaVersion": "maggie-design-textinput-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings}
379
386
 
380
387
 
388
+ SHEET_ALLOW = "maggie-allow-sheet-close"
389
+ SHEET_CLOSE = re.compile(r"\bset(?:\w*(?:Sheet|Modal|Drawer|Picker|Dialog))\s*\(\s*(?:null|undefined|false)\s*\)|\bset\w*(?:Open|Visible|Shown|Show)\s*\(\s*false\s*\)|\b(?:onClose|closeSheet|closeModal|dismiss|onDismiss)\s*\(\s*\)")
390
+ SHEET_DEFER_NAMES = ("afterSheetCloses", "setTimeout", "requestAnimationFrame", "queueMicrotask", "InteractionManager.runAfterInteractions", "runAfterInteractions")
391
+ SHEET_DEFER = re.compile(r"\b(?:" + "|".join(map(re.escape, SHEET_DEFER_NAMES)) + r")\s*\(")
392
+ SHEET_TRIGGERS = (
393
+ ("toast", re.compile(r"\b(?:toast|showToast|Toast\.show)\s*\(")),
394
+ ("query-refresh", re.compile(r"\b(?:refetch|invalidateQueries|refetchQueries|resetQueries|mutate|revalidate|reload|refresh)\s*\(")),
395
+ ("set-query-data", re.compile(r"\bsetQueryData\s*\(")),
396
+ ("navigation", re.compile(r"\b(?:router|navigation|navigator)\.(?:push|replace|navigate|back|dismiss\w*)\s*\(|\bLinking\.openURL\s*\(")),
397
+ ("another-sheet", re.compile(r"\bset\w*(?:Sheet|Modal|Drawer|Picker|Dialog)\s*\(\s*(?!null|undefined|false)[^)\s]")),
398
+ ("another-sheet", re.compile(r"\bset\w*(?:Open|Visible|Shown|Show)\s*\(\s*true\s*\)")),
399
+ ("navigation", re.compile(r"\b\w+\.open\s*\(|\bopenSheet\s*\(")),
400
+ )
401
+ # A parent's callback often toasts or refreshes; worth a look, not an error.
402
+ SHEET_CALLBACK = re.compile(r"(?<![.\w])on[A-Z]\w*\s*\(")
403
+ SHEET_PROP = re.compile(r"<(?:\w+\.)?\w*(?:Sheet|Modal|Drawer|Dialog)\b[^>]*?\b(?:open|visible|isOpen|isVisible)\s*=\s*\{([^{}]*)\}", re.S)
404
+
405
+
406
+ SHEET_PAUSE = re.compile(r"\bawait\s+(?:new\s+Promise\s*\([^;]*?\bsetTimeout\b|(?:sleep|delay|wait|pause)\s*\()")
407
+
408
+
409
+ def _defer_pattern(source: str) -> re.Pattern[str]:
410
+ """Built-in deferrals plus local helpers whose body defers (e.g. `const later = (fn) => { ... setTimeout ... }`)."""
411
+ names = []
412
+ for match in re.finditer(r"(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\([^\n]*?\)\s*(?::[^={\n]+)?=>\s*\{|function\s+(\w+)\s*\([^\n]*?\)[^{\n]*\{", source):
413
+ body = source[match.end():match.end() + 600]
414
+ if re.search(r"\b(?:setTimeout|afterSheetCloses|requestAnimationFrame|runAfterInteractions)\b", body.split("\n}", 1)[0]):
415
+ names.append(match.group(1) or match.group(2))
416
+ if not names:
417
+ return SHEET_DEFER
418
+ return re.compile(r"\b(?:" + "|".join(map(re.escape, (*SHEET_DEFER_NAMES, *names))) + r")\s*\(")
419
+
420
+
421
+ def _call_end(source: str, open_paren: int) -> int:
422
+ level, cursor = 0, open_paren
423
+ while cursor < len(source):
424
+ if source[cursor] == "(":
425
+ level += 1
426
+ elif source[cursor] == ")":
427
+ level -= 1
428
+ if level == 0:
429
+ return cursor
430
+ cursor += 1
431
+ return len(source)
432
+
433
+
434
+ def _same_tick(source: str, start: int, defer: re.Pattern[str]) -> str:
435
+ """Text that runs synchronously after `start` in the same block, with deferred callbacks blanked out."""
436
+ out: list[str] = []
437
+ depth = 0
438
+ index = start
439
+ while index < len(source):
440
+ deferred = defer.match(source, index)
441
+ if deferred:
442
+ index = _call_end(source, deferred.end() - 1) + 1
443
+ continue
444
+ if SHEET_PAUSE.match(source, index):
445
+ break # awaiting a timer longer than nothing: the rest runs later
446
+ char = source[index]
447
+ if char in "({[":
448
+ depth += 1
449
+ elif char in ")}]":
450
+ depth -= 1
451
+ if depth < 0:
452
+ break
453
+ out.append(char)
454
+ index += 1
455
+ return "".join(out)
456
+
457
+
458
+ def sheet_check(project: Path, paths: list[Path]) -> dict[str, object]:
459
+ project = project.resolve()
460
+ files = iter_files(paths or [project], (".tsx", ".jsx", ".ts", ".js"))
461
+ findings: list[dict[str, object]] = []
462
+ for path in files:
463
+ raw = path.read_text(encoding="utf-8", errors="replace")
464
+ if SHEET_ALLOW in raw or not re.search(r"\bSheet\b|<Modal\b|BottomSheet|presentation", raw):
465
+ continue
466
+ source = _strip_jsx_comments(raw)
467
+ display = _display(path, project)
468
+ # The state behind each sheet's open/visible prop: its setter closes the sheet.
469
+ names = {name for expression in SHEET_PROP.findall(source) for name in re.findall(r"(?<![.\w])([a-z]\w*)\b", re.sub(r"(['\"`])(?:\\.|(?!\1).)*\1", "", expression)) if name not in {"true", "false", "null", "undefined"}}
470
+ setters = [f"set{name[0].upper()}{name[1:]}" for name in names]
471
+ close_pattern = SHEET_CLOSE if not setters else re.compile(SHEET_CLOSE.pattern + "|\\b(?:" + "|".join(map(re.escape, setters)) + ")\\s*\\(\\s*(?:null|undefined|false|\"\"|'')\\s*\\)")
472
+ defer = _defer_pattern(source)
473
+ deferred_spans = [(match.end(), _call_end(source, match.end() - 1)) for match in defer.finditer(source)]
474
+ for close in close_pattern.finditer(source):
475
+ # A close inside a deferred callback already runs after the slide.
476
+ if any(begin <= close.start() <= end for begin, end in deferred_spans):
477
+ continue
478
+ # `const close = () => setOpen(false)`: the function only closes.
479
+ if re.search(r"=>\s*$", source[max(0, close.start() - 40):close.start()]):
480
+ continue
481
+ after = _same_tick(source, close.end(), defer)
482
+ seen: set[str] = set()
483
+ for kind, pattern in SHEET_TRIGGERS:
484
+ match = pattern.search(after)
485
+ if match and kind not in seen:
486
+ seen.add(kind)
487
+ findings.append({"file": display, "line": _line(source, close.start()), "kind": kind, "severity": "error", "close": close.group(0), "then": match.group(0).strip(),
488
+ "message": f"`{close.group(0)}` is followed in the same tick by {kind.replace('-', ' ')} (`{match.group(0).strip()}`); on iOS the closing sheet can stay on screen and take every touch. Close first, then run the rest in afterSheetCloses(() => ...)."})
489
+ callback = SHEET_CALLBACK.search(after)
490
+ if callback and not seen:
491
+ findings.append({"file": display, "line": _line(source, close.start()), "kind": "parent-callback", "severity": "warning", "close": close.group(0), "then": callback.group(0).strip(),
492
+ "message": f"`{close.group(0)}` is followed by `{callback.group(0).strip()}`; if that callback toasts, refreshes, navigates or closes another sheet, defer it with afterSheetCloses."})
493
+ # Content tied to the open state: closing (x = null) also empties the sheet while it slides away.
494
+ for tag in re.finditer(r"<(?:\w+\.)?\w*(?:Sheet|Modal|Drawer|Dialog)\b[^>]*?\b(?:open|visible|isOpen|isVisible)\s*=\s*\{\s*(?:Boolean\(\s*(\w+)\s*\)|!!\s*(\w+)|(\w+)\s*!==?\s*(?:null|undefined))\s*\}", source, re.S):
495
+ name = next(group for group in tag.groups() if group)
496
+ rest = source[tag.end():tag.end() + 1500]
497
+ body = rest[:rest.find("</")] if "</" in rest else rest
498
+ if re.search(rf"(?<![\w.]){re.escape(name)}\s*(?:\?\.|\.|\[)", source[tag.start():tag.end()] + body):
499
+ findings.append({"file": display, "line": _line(source, tag.start()), "kind": "content-tied-to-open", "severity": "warning", "close": f"set{name[0].upper()}{name[1:]}(null)", "then": f"{name}.… in the sheet",
500
+ "message": f"the sheet opens on `{name}` and shows `{name}`'s fields, so closing it (clearing `{name}`) changes its content while it slides away; keep a separate open flag and clear `{name}` in afterSheetCloses (or leave it until the next open)."})
501
+ errors = [item for item in findings if item["severity"] == "error"]
502
+ return {"schemaVersion": "maggie-design-sheet-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings,
503
+ "fix": "export function afterSheetCloses(fn: () => void) { if (Platform.OS === \"web\") fn(); else setTimeout(fn, SHEET_CLOSE_MS); } // SHEET_CLOSE_MS ~ the slide-out duration (about 450 ms)"}
504
+
505
+
381
506
  def main(argv: list[str] | None = None) -> int:
382
507
  parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
383
508
  sub = parser.add_subparsers(dest="command", required=True)
@@ -395,9 +520,14 @@ def main(argv: list[str] | None = None) -> int:
395
520
  textinput = sub.add_parser("textinput-check", help="flag multiline TextInput without a height and grow-only auto-grow on React Native Web (.tsx/.jsx)")
396
521
  textinput.add_argument("--project", type=Path, default=Path.cwd())
397
522
  textinput.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
523
+ sheet = sub.add_parser("sheet-check", help="flag closing a modal sheet and, in the same tick, a toast, refresh, query write, navigation or another sheet (iOS freeze)")
524
+ sheet.add_argument("--project", type=Path, default=Path.cwd())
525
+ sheet.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
398
526
  args = parser.parse_args(argv)
399
527
  try:
400
- if args.command == "textinput-check":
528
+ if args.command == "sheet-check":
529
+ report = sheet_check(args.project, args.path)
530
+ elif args.command == "textinput-check":
401
531
  report = textinput_check(args.project, args.path)
402
532
  elif args.command == "safe-area-check":
403
533
  report = safe_area_check(args.project, args.path)