@topy-ai/maggie 0.7.52 → 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 (79) hide show
  1. package/README-zh-TW.md +28 -4
  2. package/README.md +93 -60
  3. package/bin/maggie.js +67 -12
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  12. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  13. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  14. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  15. package/bundled-references/provider-adapter-contract.md +16 -3
  16. package/bundled-skills/catalog.json +1 -1
  17. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  18. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  19. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  20. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  21. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  22. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  23. package/bundled-skills/maggie-booking/SKILL.md +66 -14
  24. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  25. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  26. package/bundled-skills/maggie-deployment/SKILL.md +48 -0
  27. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  28. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  29. package/bundled-skills/maggie-design/SKILL.md +98 -3
  30. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  31. package/bundled-skills/maggie-feedback/SKILL.md +27 -2
  32. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  33. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  34. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  35. package/bundled-skills/maggie-mobile-app/SKILL.md +65 -6
  36. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  37. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  38. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  39. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  40. package/bundled-skills/maggie-social-share/SKILL.md +56 -4
  41. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  42. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  43. package/bundled-tools/clis/maggie_booking.py +24 -12
  44. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  45. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  46. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  47. package/bundled-tools/clis/maggie_dash.py +173 -3
  48. package/bundled-tools/clis/maggie_deployment_ops.py +524 -0
  49. package/bundled-tools/clis/maggie_design.py +15 -2
  50. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  51. package/bundled-tools/clis/maggie_design_lint.py +205 -3
  52. package/bundled-tools/clis/maggie_feedback.py +138 -28
  53. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  54. package/bundled-tools/clis/maggie_localization.py +3 -0
  55. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  56. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  57. package/bundled-tools/clis/maggie_mobile.py +219 -4
  58. package/bundled-tools/clis/maggie_product_release.py +49 -1
  59. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  60. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  61. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  62. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  63. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  64. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  65. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  66. package/bundled-tools/clis/maggie_social_share.py +174 -7
  67. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  68. package/bundled-tools/runtime/assistant_approval.py +98 -0
  69. package/package.json +1 -1
  70. package/references/provider-adapter-contract.md +16 -3
  71. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  72. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  73. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  74. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  75. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  76. package/bundled-references/maggiedash-booking/README.md +0 -288
  77. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  78. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  79. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -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
@@ -7,7 +7,10 @@ pressable in JSX/TSX. React Native Web renders ``Pressable`` and the
7
7
  remove control becomes ``<button>`` inside ``<button>``: invalid DOM, and taps
8
8
  land on the wrong control.
9
9
 
10
- ``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities.
10
+ ``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities,
11
+ and warns about ``width: max-content`` (or ``w-max``) strips that can push a
12
+ 390 px page sideways. ``textinput-check`` flags React Native Web multiline
13
+ inputs without a height and grow-only auto-grow.
11
14
  Tailwind v4 emits utilities inside ``@layer utilities``; any unlayered rule
12
15
  wins over every layer regardless of specificity, so an unlayered
13
16
  ``h1 { font-family }`` beats ``font-*`` and ``.ph { display: inline-block }``
@@ -19,6 +22,13 @@ Expo Router modal route (``presentation: "transparentModal"``, ``"modal"``,
19
22
  surfaces can measure a 0 top inset, so the header and Close button sit under
20
23
  the notch or Dynamic Island; web screenshots cannot show it. Read insets with
21
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).
22
32
  """
23
33
 
24
34
  from __future__ import annotations
@@ -239,12 +249,41 @@ def _rule_findings(source: str, start: int, end: int, layered: bool, display: st
239
249
  index = close + 1
240
250
 
241
251
 
252
+ MAX_CONTENT_RULE = re.compile(r"([^{}]+)\{([^{}]*\b(?:min-)?width\s*:\s*max-content[^{}]*)\}", re.I)
253
+ MAX_CONTENT_CLASS = re.compile(r"class(?:Name)?\s*=\s*[\"'`{]([^\"'`}]*\bw-max\b[^\"'`}]*)", re.I)
254
+ OVERFLOW_GUARD = re.compile(r"\b(?:max-width|overflow(?:-x)?)\s*:", re.I)
255
+
256
+
257
+ def overflow_findings(project: Path, css_sources: dict[Path, str], markup: list[Path]) -> list[dict[str, object]]:
258
+ """width: max-content on a tab strip or toolbar makes a phone page scroll sideways unless it can shrink or scroll."""
259
+ findings: list[dict[str, object]] = []
260
+ for path, source in css_sources.items():
261
+ for match in MAX_CONTENT_RULE.finditer(source):
262
+ body = match.group(2)
263
+ if OVERFLOW_GUARD.search(body):
264
+ continue
265
+ selector = " ".join(match.group(1).split())[-80:]
266
+ findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start(2)) + 1, "selector": selector, "kind": "max-content", "severity": "warning",
267
+ "message": "width: max-content without max-width/overflow can push a 390 px page sideways; give the strip overflow-x: auto inside a min-width: 0 parent, or max-width: 100%"})
268
+ for path in markup:
269
+ source = path.read_text(encoding="utf-8", errors="replace")
270
+ for match in MAX_CONTENT_CLASS.finditer(source):
271
+ classes = match.group(1)
272
+ if re.search(r"\b(?:overflow-x-auto|overflow-auto|overflow-x-scroll|max-w-[\w\[\]%-]+)\b", classes):
273
+ continue
274
+ findings.append({"file": _display(path, project), "line": source.count("\n", 0, match.start()) + 1, "selector": classes.strip()[:80], "kind": "max-content", "severity": "warning",
275
+ "message": "w-max without overflow-x-auto or a max-w-* can make the page scroll sideways at 390 px"})
276
+ return findings
277
+
278
+
242
279
  def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, object]:
243
280
  files = iter_files(paths or [project], (".css", ".pcss", ".postcss"))
244
281
  sources = {path.resolve(): _strip_css_comments(path.read_text(encoding="utf-8", errors="replace")) for path in files}
282
+ markup = iter_files(paths or [project], (".tsx", ".jsx", ".astro", ".vue", ".svelte", ".html"))
283
+ overflow = overflow_findings(project, sources, markup)
245
284
  tailwind_v4 = force_v4 or any(TAILWIND_V4_IMPORT.search(source) for source in sources.values())
246
285
  if not tailwind_v4:
247
- return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": []}
286
+ return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": overflow, "warnings": len(overflow)}
248
287
  layered_files: set[Path] = set()
249
288
  for path, source in sources.items():
250
289
  for target in LAYERED_IMPORT.findall(source):
@@ -252,6 +291,7 @@ def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, o
252
291
  findings: list[dict[str, object]] = []
253
292
  for path, source in sources.items():
254
293
  _rule_findings(source, 0, len(source), path in layered_files, _display(path, project), findings)
294
+ findings += overflow
255
295
  errors = [item for item in findings if item["severity"] == "error"]
256
296
  return {"schemaVersion": "maggie-design-layer-check.v1", "passed": not errors, "status": "checked", "checkedFiles": len(files), "errors": len(errors), "warnings": len(findings) - len(errors), "findings": findings}
257
297
 
@@ -311,6 +351,158 @@ def safe_area_check(project: Path, paths: list[Path]) -> dict[str, object]:
311
351
  "recommendation": "Wrap the app once in SafeAreaProvider; in modals read const insets = useSafeAreaInsets() and pad the header/footer. Verify on a notched iPhone (or simulator) — web screenshots cannot show the inset."}
312
352
 
313
353
 
354
+ TEXTINPUT_TAG = re.compile(r"<TextInput\b(?:[^<>]|=>|\{[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[^{}]*)*\})*?/?>", re.S)
355
+ TEXTINPUT_ALLOW = "maggie-allow-textinput"
356
+
357
+
358
+ def textinput_check(project: Path, paths: list[Path]) -> dict[str, object]:
359
+ """React Native Web renders a multiline TextInput as a 2-row textarea; flag inputs without a height and grow-only auto-grow."""
360
+ project = project.resolve()
361
+ files = iter_files(paths or [project], (".tsx", ".jsx"))
362
+ findings: list[dict[str, object]] = []
363
+ for path in files:
364
+ raw = path.read_text(encoding="utf-8", errors="replace")
365
+ if "TextInput" not in raw or TEXTINPUT_ALLOW in raw:
366
+ continue
367
+ source = _strip_jsx_comments(raw)
368
+ display = _display(path, project)
369
+ for tag in TEXTINPUT_TAG.finditer(source):
370
+ text = tag.group(0)
371
+ if not re.search(r"\bmultiline\b(?!\s*=\s*\{\s*false\s*\})", text):
372
+ continue
373
+ line = _line(source, tag.start())
374
+ sized = re.search(r"\bnumberOfLines\s*=|\brows\s*=|\b(?:height|minHeight)\s*:", text)
375
+ style_ref = re.search(r"\bstyle\s*=\s*\{\s*(?:\[)?\s*(?:styles|s)\.(\w+)", text)
376
+ if not sized and style_ref and re.search(rf"\b{style_ref.group(1)}\s*:\s*\{{[^{{}}]*\b(?:height|minHeight)\s*:", source):
377
+ sized = True
378
+ if not sized:
379
+ findings.append({"file": display, "line": line, "kind": "multiline-without-height", "severity": "error",
380
+ "message": "multiline TextInput without numberOfLines or an explicit height renders a 2-row textarea on web (placeholder high, buttons misaligned); set one line of height and grow by measuring"})
381
+ if "onContentSizeChange" in text and "scrollHeight" not in source:
382
+ findings.append({"file": display, "line": line, "kind": "grow-only-autogrow", "severity": "warning",
383
+ "message": "onContentSizeChange never reports a smaller size on web, so this composer grows but never shrinks; on web collapse to the minimum, read scrollHeight, then set the height"})
384
+ errors = [item for item in findings if item["severity"] == "error"]
385
+ return {"schemaVersion": "maggie-design-textinput-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings}
386
+
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
+
314
506
  def main(argv: list[str] | None = None) -> int:
315
507
  parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
316
508
  sub = parser.add_subparsers(dest="command", required=True)
@@ -325,9 +517,19 @@ def main(argv: list[str] | None = None) -> int:
325
517
  safe = sub.add_parser("safe-area-check", help="flag SafeAreaView inside Expo Router modal routes and React Native Modal (.tsx/.jsx)")
326
518
  safe.add_argument("--project", type=Path, default=Path.cwd())
327
519
  safe.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
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)")
521
+ textinput.add_argument("--project", type=Path, default=Path.cwd())
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")
328
526
  args = parser.parse_args(argv)
329
527
  try:
330
- if args.command == "safe-area-check":
528
+ if args.command == "sheet-check":
529
+ report = sheet_check(args.project, args.path)
530
+ elif args.command == "textinput-check":
531
+ report = textinput_check(args.project, args.path)
532
+ elif args.command == "safe-area-check":
331
533
  report = safe_area_check(args.project, args.path)
332
534
  elif args.command == "pressable-check":
333
535
  report = pressable_check(args.project, args.path, args.pressable)