@topy-ai/maggie 0.7.46 → 0.7.48
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.
- package/README-zh-TW.md +12 -4
- package/README.md +55 -11
- package/bin/maggie.js +26 -4
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/memory-hook.md +18 -5
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-deployment/SKILL.md +59 -1
- package/bundled-skills/maggie-deployment/references/vps.md +41 -0
- package/bundled-skills/maggie-design/SKILL.md +99 -0
- package/bundled-skills/maggie-feedback/SKILL.md +41 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-memory/SKILL.md +10 -1
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
- package/bundled-skills/maggie-social-share/SKILL.md +37 -5
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +295 -0
- package/bundled-tools/clis/maggie_design.py +9 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_feedback.py +107 -3
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_memory.py +10 -3
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_social_queue.py +338 -0
- package/bundled-tools/clis/maggie_social_share.py +42 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/maggie_memory.py +17 -2
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/memory-hook.md +18 -5
- package/references/provider-adapter-contract.md +33 -0
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
|
+
import os
|
|
8
9
|
import re
|
|
9
10
|
import shutil
|
|
10
11
|
import subprocess
|
|
@@ -17,6 +18,9 @@ from html.parser import HTMLParser
|
|
|
17
18
|
from html import escape, unescape
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
|
+
import maggie_template_package as package # noqa: E402
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
22
26
|
REQUIRED_METADATA = ("id", "name", "status", "framework", "industry", "source")
|
|
@@ -382,7 +386,7 @@ def catalog_entries(root: Path) -> list[dict]:
|
|
|
382
386
|
|
|
383
387
|
def update_catalog(root: Path, metadata: dict) -> None:
|
|
384
388
|
entries = [item for item in catalog_entries(root) if item.get("id") != metadata.get("id")]
|
|
385
|
-
entries.append({key: metadata[key] for key in ("id", "name", "status", "framework", "industry", "pageTypes", "styleTags", "source", "supports") if key in metadata})
|
|
389
|
+
entries.append({key: metadata[key] for key in ("id", "name", "displayName", "displayDescription", "status", "framework", "industry", "pageTypes", "styleTags", "source", "supports") if key in metadata})
|
|
386
390
|
entries.sort(key=lambda item: item.get("id", ""))
|
|
387
391
|
save_catalog(root, entries)
|
|
388
392
|
|
|
@@ -1185,6 +1189,8 @@ header:first-of-type a[class*="border-white"], #home a[class*="border-white"] {
|
|
|
1185
1189
|
metadata["generatedAssets"] = {"logo": generated[0]["path"], "hero": generated[1]["path"], "careTeam": generated[2]["path"], "provider": "maggie-marketplace-pipeline", "generatedAt": now()}
|
|
1186
1190
|
metadata["sourceMetadata"]["description"] = description
|
|
1187
1191
|
write_json(destination / "template.json", metadata)
|
|
1192
|
+
finalise_package_identity(root, destination, getattr(args, "display_name", None), getattr(args, "display_description", None))
|
|
1193
|
+
metadata = read_json(destination / "template.json")
|
|
1188
1194
|
(destination / "README.md").write_text(f"# {metadata['name']}\n\nA UK-localised Astro homepage template derived from {display_name}. Includes clone evidence, responsive screenshots, locally mapped assets, SEO metadata, Phosphor icons, and a DESIGN.md contract.\n\nThis entry remains a draft until source reuse and asset licensing are reviewed.\n", encoding="utf-8")
|
|
1189
1195
|
cleanup_template_assets(destination)
|
|
1190
1196
|
scrub_local_export_references(destination, args.preview_url)
|
|
@@ -1192,6 +1198,29 @@ header:first-of-type a[class*="border-white"], #home a[class*="border-white"] {
|
|
|
1192
1198
|
update_catalog(root, read_json(destination / "template.json"))
|
|
1193
1199
|
|
|
1194
1200
|
|
|
1201
|
+
def finalise_package_identity(root: Path, destination: Path, display_name: str | None = None, display_description: str | None = None, rewrite_design: bool = True) -> dict:
|
|
1202
|
+
"""Measure design tokens, record a neutral display identity, and review screenshots."""
|
|
1203
|
+
metadata = read_json(destination / "template.json")
|
|
1204
|
+
measured = package.measure_design_tokens((destination / "homepage.html").read_text(encoding="utf-8", errors="replace"))
|
|
1205
|
+
if rewrite_design and (destination / "DESIGN.md").exists():
|
|
1206
|
+
package.write_measured_design(destination, measured)
|
|
1207
|
+
metadata["designTokens"] = {"palette": measured["roles"], "textSecondary": measured.get("textSecondary"), "border": measured["border"],
|
|
1208
|
+
"fonts": {key: measured["fonts"][key] for key in ("display", "body", "missingFontLinks")}, "measuredFrom": "homepage.html", "measuredAt": now()}
|
|
1209
|
+
if not metadata.get("styleTags"):
|
|
1210
|
+
metadata["styleTags"] = package.derived_style_tags(measured)
|
|
1211
|
+
taken = {str(entry.get("displayName", "")).lower() for entry in catalog_entries(root) if entry.get("id") != metadata.get("id")}
|
|
1212
|
+
if display_name or not metadata.get("displayName"):
|
|
1213
|
+
identity = package.generate_display_identity(metadata, measured, taken)
|
|
1214
|
+
metadata["displayName"] = display_name or identity["displayName"]
|
|
1215
|
+
metadata["displayDescription"] = display_description or (metadata.get("displayDescription") if display_name else None) or identity["displayDescription"]
|
|
1216
|
+
elif display_description:
|
|
1217
|
+
metadata["displayDescription"] = display_description
|
|
1218
|
+
metadata["screenshotReview"] = package.screenshot_report(destination)
|
|
1219
|
+
write_json(destination / "template.json", metadata)
|
|
1220
|
+
update_catalog(root, metadata)
|
|
1221
|
+
return metadata
|
|
1222
|
+
|
|
1223
|
+
|
|
1195
1224
|
def standardize_phosphor_icons(value: str) -> tuple[str, list[dict]]:
|
|
1196
1225
|
"""Inventory source icon semantics, then replace each with a Phosphor class."""
|
|
1197
1226
|
mapping = {
|
|
@@ -1676,7 +1705,12 @@ def audit_image_replacements(path: Path) -> list[str]:
|
|
|
1676
1705
|
Path(src.split("?", 1)[0]).name
|
|
1677
1706
|
for src in re.findall(r"assets/[^\s,\"'?#]+", value, re.I)
|
|
1678
1707
|
}
|
|
1679
|
-
|
|
1708
|
+
# repair-assets may add a sniffed suffix to an extensionless capture, so a
|
|
1709
|
+
# suffix-less source name also matches its renamed `<name>.<ext>` file.
|
|
1710
|
+
renamed = {Path(name).stem: name for name in homepage_paths if Path(name).suffix}
|
|
1711
|
+
leaked = sorted({path_name if path_name in homepage_paths else renamed[path_name]
|
|
1712
|
+
for path_name in source_paths
|
|
1713
|
+
if path_name in homepage_paths or (not Path(path_name).suffix and path_name in renamed)})
|
|
1680
1714
|
errors = [f"homepage still references captured source image: assets/{name}" for name in leaked]
|
|
1681
1715
|
generated_assets = [
|
|
1682
1716
|
item for item in generated_manifest.get("assets", [])
|
|
@@ -1834,6 +1868,7 @@ def marketplace_artifact_gate(destination: Path, require_generated_assets: bool
|
|
|
1834
1868
|
errors.extend(audit_visual_contrast(destination))
|
|
1835
1869
|
errors.extend(audit_mobile_navigation(destination))
|
|
1836
1870
|
errors.extend(audit_image_replacements(destination))
|
|
1871
|
+
errors.extend(package_quality_errors(destination))
|
|
1837
1872
|
update_template_tasks(destination, "COMPLETED")
|
|
1838
1873
|
try:
|
|
1839
1874
|
task_state = read_json(destination / "tasks.json")
|
|
@@ -1974,6 +2009,16 @@ def resume_marketplace_job(args: argparse.Namespace) -> int:
|
|
|
1974
2009
|
return run_marketplace_job(resumed)
|
|
1975
2010
|
|
|
1976
2011
|
|
|
2012
|
+
def package_quality_errors(path: Path) -> list[str]:
|
|
2013
|
+
"""Screenshot, measured-palette, provenance, identity, and image-review gates."""
|
|
2014
|
+
try:
|
|
2015
|
+
catalog = catalog_entries(path.parent.parent)
|
|
2016
|
+
except ValueError:
|
|
2017
|
+
catalog = []
|
|
2018
|
+
report = package.package_audit(path, catalog)
|
|
2019
|
+
return [error for group in report["checks"].values() for error in group]
|
|
2020
|
+
|
|
2021
|
+
|
|
1977
2022
|
def validate_template(path: Path, sample: Path | None = None) -> int:
|
|
1978
2023
|
normalise_phosphor_template(path)
|
|
1979
2024
|
cleanup_template_assets(path)
|
|
@@ -1991,6 +2036,7 @@ def validate_template(path: Path, sample: Path | None = None) -> int:
|
|
|
1991
2036
|
errors.extend(audit_visual_contrast(path))
|
|
1992
2037
|
errors.extend(audit_mobile_navigation(path))
|
|
1993
2038
|
errors.extend(audit_image_replacements(path))
|
|
2039
|
+
errors.extend(package_quality_errors(path))
|
|
1994
2040
|
if not metadata_path.exists():
|
|
1995
2041
|
errors.append("missing template.json")
|
|
1996
2042
|
metadata = {}
|
|
@@ -2136,7 +2182,7 @@ def list_templates(args: argparse.Namespace) -> int:
|
|
|
2136
2182
|
if args.published:
|
|
2137
2183
|
entries = [entry for entry in entries if entry.get("status") in PUBLISHED_STATUSES]
|
|
2138
2184
|
for entry in entries:
|
|
2139
|
-
print(f"{entry.get('id')}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
|
|
2185
|
+
print(f"{entry.get('id')}\t{entry.get('displayName') or '(display name missing)'}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
|
|
2140
2186
|
return 0
|
|
2141
2187
|
|
|
2142
2188
|
|
|
@@ -2157,7 +2203,7 @@ def suggest_templates(args: argparse.Namespace) -> int:
|
|
|
2157
2203
|
ranked = []
|
|
2158
2204
|
for entry in catalog_entries(marketplace_root(args.marketplace)):
|
|
2159
2205
|
haystack = set(re.findall(r"[a-z0-9]+", " ".join([
|
|
2160
|
-
str(entry.get("
|
|
2206
|
+
str(entry.get("displayName", "")), str(entry.get("displayDescription", "")), " ".join(entry.get("industry", [])),
|
|
2161
2207
|
" ".join(entry.get("styleTags", [])), " ".join(entry.get("pageTypes", [])),
|
|
2162
2208
|
]).lower()))
|
|
2163
2209
|
score = len(query & haystack)
|
|
@@ -2166,7 +2212,7 @@ def suggest_templates(args: argparse.Namespace) -> int:
|
|
|
2166
2212
|
ranked.append((score, entry))
|
|
2167
2213
|
ranked.sort(key=lambda item: (-item[0], item[1].get("id", "")))
|
|
2168
2214
|
for score, entry in ranked[:args.limit]:
|
|
2169
|
-
print(f"{score}\t{entry.get('id')}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
|
|
2215
|
+
print(f"{score}\t{entry.get('id')}\t{entry.get('displayName') or '(display name missing)'}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
|
|
2170
2216
|
return 0
|
|
2171
2217
|
|
|
2172
2218
|
|
|
@@ -2235,6 +2281,107 @@ def enrichment_report(args: argparse.Namespace) -> int:
|
|
|
2235
2281
|
print(json.dumps({"status": report["status"], "candidateId": candidate_id, "report": str(output), "retry": report["retry"]}, ensure_ascii=False)); return 0 if not errors else 1
|
|
2236
2282
|
|
|
2237
2283
|
|
|
2284
|
+
def _template_dir(args: argparse.Namespace) -> tuple[Path, Path]:
|
|
2285
|
+
root = marketplace_root(args.marketplace)
|
|
2286
|
+
source = template_path(root, args.id)
|
|
2287
|
+
if not (source / "template.json").is_file():
|
|
2288
|
+
raise ValueError(f"template not found: {args.id}")
|
|
2289
|
+
return root, source
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
def audit_package(args: argparse.Namespace) -> int:
|
|
2293
|
+
"""Read-only package-quality audit; --record stores the screenshot review."""
|
|
2294
|
+
root, source = _template_dir(args)
|
|
2295
|
+
report = package.package_audit(source, catalog_entries(root))
|
|
2296
|
+
if args.record:
|
|
2297
|
+
metadata = read_json(source / "template.json")
|
|
2298
|
+
metadata["screenshotReview"] = package.screenshot_report(source)
|
|
2299
|
+
if args.recapture_note:
|
|
2300
|
+
metadata["screenshotReview"]["note"] = args.recapture_note
|
|
2301
|
+
write_json(source / "template.json", metadata)
|
|
2302
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
2303
|
+
return 0 if report["passed"] else 1
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
def measure_design(args: argparse.Namespace) -> int:
|
|
2307
|
+
root, source = _template_dir(args)
|
|
2308
|
+
measured = package.measure_design_tokens((source / "homepage.html").read_text(encoding="utf-8", errors="replace"))
|
|
2309
|
+
if args.palette_roles:
|
|
2310
|
+
palettes = package.load_palettes(None, measured, read_json(source / "template.json").get("id"))
|
|
2311
|
+
args.palette_roles.parent.mkdir(parents=True, exist_ok=True)
|
|
2312
|
+
args.palette_roles.write_text(json.dumps(palettes, indent=2) + "\n", encoding="utf-8")
|
|
2313
|
+
if args.write:
|
|
2314
|
+
finalise_package_identity(root, source, rewrite_design=True)
|
|
2315
|
+
print(json.dumps({key: measured[key] for key in ("roles", "textSecondary", "border", "fonts")}, indent=2, ensure_ascii=False))
|
|
2316
|
+
return 0
|
|
2317
|
+
|
|
2318
|
+
|
|
2319
|
+
def repair_assets(args: argparse.Namespace) -> int:
|
|
2320
|
+
_, source = _template_dir(args)
|
|
2321
|
+
result = package.repair_asset_provenance(source, write=args.write)
|
|
2322
|
+
print(json.dumps({"write": args.write, **result}, indent=2, ensure_ascii=False))
|
|
2323
|
+
return 0
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
def set_identity(args: argparse.Namespace) -> int:
|
|
2327
|
+
root, source = _template_dir(args)
|
|
2328
|
+
metadata = finalise_package_identity(root, source, args.display_name, args.display_description, rewrite_design=False)
|
|
2329
|
+
errors = package.audit_display_identity(source, catalog_entries(root))
|
|
2330
|
+
print(json.dumps({"displayName": metadata.get("displayName"), "displayDescription": metadata.get("displayDescription"), "errors": errors}, indent=2, ensure_ascii=False))
|
|
2331
|
+
return 0 if not errors else 1
|
|
2332
|
+
|
|
2333
|
+
|
|
2334
|
+
def template_studio(args: argparse.Namespace) -> int:
|
|
2335
|
+
root, source = _template_dir(args)
|
|
2336
|
+
measured = package.measure_design_tokens((source / "homepage.html").read_text(encoding="utf-8", errors="replace"))
|
|
2337
|
+
palettes = package.load_palettes(args.palettes.resolve() if args.palettes else None, measured, args.id)
|
|
2338
|
+
output = (args.output or root / ".maggie" / "template-studio" / args.id).resolve()
|
|
2339
|
+
result = package.build_studio(source, output, palettes)
|
|
2340
|
+
if args.check_overflow:
|
|
2341
|
+
serve_root = Path(os.path.commonpath([str(output), str(source.resolve())]))
|
|
2342
|
+
report = package.check_studio_overflow(serve_root, output / "index.html", palette_id=args.palette)
|
|
2343
|
+
result["overflow"] = report
|
|
2344
|
+
_write = output / "overflow-report.json"
|
|
2345
|
+
_write.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
2346
|
+
failing = {width: item for width, item in (report.get("viewports") or {}).items() if item.get("overflowPx")}
|
|
2347
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
2348
|
+
return 1 if failing or report.get("error") or not report.get("viewports") else 0
|
|
2349
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
2350
|
+
print("Serve the common parent over HTTP (python3 -m http.server) to preview; --check-overflow renders it headlessly.")
|
|
2351
|
+
return 0
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
def content_validate(args: argparse.Namespace) -> int:
|
|
2355
|
+
content = read_json(args.input.resolve())
|
|
2356
|
+
errors = package.validate_content(content)
|
|
2357
|
+
print(json.dumps({"status": "passed" if not errors else "failed", "errors": errors}, indent=2))
|
|
2358
|
+
return 0 if not errors else 1
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
def content_css(args: argparse.Namespace) -> int:
|
|
2362
|
+
content = read_json(args.input.resolve())
|
|
2363
|
+
roles = (content.get("palette") or {}).get("roles") or {}
|
|
2364
|
+
if not roles:
|
|
2365
|
+
raise ValueError("content has no palette.roles to export")
|
|
2366
|
+
print(package.palette_css_variables(roles), end="")
|
|
2367
|
+
return 0
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def image_plan(args: argparse.Namespace) -> int:
|
|
2371
|
+
_, source = _template_dir(args)
|
|
2372
|
+
report = package.plan_image_generation(source, args.unit_cost, args.currency, args.provider)
|
|
2373
|
+
print(json.dumps(report["before"], indent=2))
|
|
2374
|
+
return 0
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
def image_review(args: argparse.Namespace) -> int:
|
|
2378
|
+
_, source = _template_dir(args)
|
|
2379
|
+
report = package.review_generated_images(source, approve=args.approve, reviewer=args.reviewer or "", flags=args.flag, note=args.note or "")
|
|
2380
|
+
summary = {"after": report["after"], "textReview": {key: report["textReview"][key] for key in ("status", "method", "contactSheet")}}
|
|
2381
|
+
print(json.dumps(summary, indent=2))
|
|
2382
|
+
return 0 if report["textReview"]["status"] == "passed" else 1
|
|
2383
|
+
|
|
2384
|
+
|
|
2238
2385
|
def main() -> int:
|
|
2239
2386
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
2240
2387
|
parser.add_argument("--marketplace", type=Path, default=Path("marketplace"))
|
|
@@ -2269,6 +2416,8 @@ def main() -> int:
|
|
|
2269
2416
|
add.add_argument("--languages", action="append", default=["en"])
|
|
2270
2417
|
add.add_argument("--translation-policy", choices=("source-only", "review-required", "fully-localized"), default="review-required")
|
|
2271
2418
|
add.add_argument("--brand", default="Maggie Studio", help="neutral demo brand used by the completion pipeline")
|
|
2419
|
+
add.add_argument("--display-name", help="neutral customer-facing style name (default: generated from measured design)")
|
|
2420
|
+
add.add_argument("--display-description", help="short neutral description shown in template pickers")
|
|
2272
2421
|
add.add_argument("--site-url", help="final canonical site URL; defaults to an example.co.uk URL")
|
|
2273
2422
|
add.add_argument("--sample", type=Path)
|
|
2274
2423
|
add.add_argument("--force", action="store_true")
|
|
@@ -2319,6 +2468,51 @@ def main() -> int:
|
|
|
2319
2468
|
enrichment.add_argument("--input", type=Path, required=True)
|
|
2320
2469
|
enrichment.add_argument("--output", type=Path)
|
|
2321
2470
|
enrichment.set_defaults(func=enrichment_report)
|
|
2471
|
+
audit = sub.add_parser("audit-package", help="read-only screenshot, palette, provenance, identity, and image-review audit")
|
|
2472
|
+
audit.add_argument("id")
|
|
2473
|
+
audit.add_argument("--record", action="store_true", help="store the screenshot review in template.json")
|
|
2474
|
+
audit.add_argument("--recapture-note")
|
|
2475
|
+
audit.set_defaults(func=audit_package)
|
|
2476
|
+
measure = sub.add_parser("measure-design", help="measure palette roles and fonts from homepage CSS")
|
|
2477
|
+
measure.add_argument("id")
|
|
2478
|
+
measure.add_argument("--write", action="store_true", help="rewrite DESIGN.md palette/typography and template.json designTokens")
|
|
2479
|
+
measure.add_argument("--palette-roles", type=Path, help="write the measured signature as maggie-palette-roles-v1 JSON")
|
|
2480
|
+
measure.set_defaults(func=measure_design)
|
|
2481
|
+
repair = sub.add_parser("repair-assets", help="list unlisted assets with honest provenance; add missing file extensions")
|
|
2482
|
+
repair.add_argument("id")
|
|
2483
|
+
repair.add_argument("--write", action="store_true")
|
|
2484
|
+
repair.set_defaults(func=repair_assets)
|
|
2485
|
+
identity = sub.add_parser("identity", help="record a neutral, unique displayName and description")
|
|
2486
|
+
identity.add_argument("id")
|
|
2487
|
+
identity.add_argument("--display-name")
|
|
2488
|
+
identity.add_argument("--display-description")
|
|
2489
|
+
identity.set_defaults(func=set_identity)
|
|
2490
|
+
studio = sub.add_parser("studio", help="build a template x palette x viewport preview studio")
|
|
2491
|
+
studio.add_argument("id")
|
|
2492
|
+
studio.add_argument("--palettes", type=Path, help="maggie-palette-roles-v1 JSON (default: measured signature only)")
|
|
2493
|
+
studio.add_argument("--output", type=Path)
|
|
2494
|
+
studio.add_argument("--check-overflow", action="store_true", help="render headlessly and fail on horizontal overflow")
|
|
2495
|
+
studio.add_argument("--palette", help="palette id to render for --check-overflow (default: signature)")
|
|
2496
|
+
studio.set_defaults(func=template_studio)
|
|
2497
|
+
cvalidate = sub.add_parser("content-validate", help="validate maggie-template-content-v1 JSON")
|
|
2498
|
+
cvalidate.add_argument("--input", type=Path, required=True)
|
|
2499
|
+
cvalidate.set_defaults(func=content_validate)
|
|
2500
|
+
ccss = sub.add_parser("content-css", help="print palette CSS variables for template content")
|
|
2501
|
+
ccss.add_argument("--input", type=Path, required=True)
|
|
2502
|
+
ccss.set_defaults(func=content_css)
|
|
2503
|
+
iplan = sub.add_parser("image-plan", help="record planned image count and estimated cost before generation")
|
|
2504
|
+
iplan.add_argument("id")
|
|
2505
|
+
iplan.add_argument("--unit-cost", type=float)
|
|
2506
|
+
iplan.add_argument("--currency", default="USD")
|
|
2507
|
+
iplan.add_argument("--provider", default="built-in-image-generation")
|
|
2508
|
+
iplan.set_defaults(func=image_plan)
|
|
2509
|
+
ireview = sub.add_parser("image-review", help="contact sheet, OCR/human no-text review, and post-run cost")
|
|
2510
|
+
ireview.add_argument("id")
|
|
2511
|
+
ireview.add_argument("--approve", action="store_true", help="record a human review that found no text, labels, or watermarks")
|
|
2512
|
+
ireview.add_argument("--reviewer")
|
|
2513
|
+
ireview.add_argument("--flag", action="append", default=[], help="asset path with readable text; repeatable")
|
|
2514
|
+
ireview.add_argument("--note")
|
|
2515
|
+
ireview.set_defaults(func=image_review)
|
|
2322
2516
|
args = parser.parse_args()
|
|
2323
2517
|
try:
|
|
2324
2518
|
return int(args.func(args) or 0)
|
|
@@ -35,12 +35,15 @@ def main() -> int:
|
|
|
35
35
|
listing.add_argument("--status", choices=sorted(memory.STATUSES), default=None)
|
|
36
36
|
listing.add_argument("--skill", default="")
|
|
37
37
|
listing.add_argument("--query", default="")
|
|
38
|
+
listing.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
|
|
38
39
|
search = add_project(sub.add_parser("search"))
|
|
39
40
|
search.add_argument("query")
|
|
40
41
|
search.add_argument("--skill", default="")
|
|
42
|
+
search.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
|
|
41
43
|
context = add_project(sub.add_parser("context"))
|
|
42
44
|
context.add_argument("--skill", default="")
|
|
43
45
|
context.add_argument("--query", default="")
|
|
46
|
+
context.add_argument("--include-shared", action="store_true", help="explicitly include user/workspace memory entries")
|
|
44
47
|
add = add_project(sub.add_parser("add"))
|
|
45
48
|
add.add_argument("kind", choices=("preferences", "conventions", "lessons"))
|
|
46
49
|
add.add_argument("--scope", choices=sorted(memory.SCOPES), required=True)
|
|
@@ -85,17 +88,21 @@ def main() -> int:
|
|
|
85
88
|
output(memory.transition(args.project, args.kind, args.item_id, args.status))
|
|
86
89
|
elif args.command in {"context", "search", "list"}:
|
|
87
90
|
query = args.query if args.command == "search" else getattr(args, "query", "")
|
|
88
|
-
result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query, status=getattr(args, "status", None))
|
|
91
|
+
result = memory.relevant(args.project, skill=getattr(args, "skill", ""), query=query, status=getattr(args, "status", None), include_shared=getattr(args, "include_shared", False))
|
|
89
92
|
if args.command == "list" and args.kind:
|
|
90
93
|
result = {args.kind: result[args.kind]}
|
|
91
94
|
if args.command == "list" and args.status:
|
|
92
95
|
result = {kind: [item for item in items if item.get("status") == args.status] for kind, items in result.items()}
|
|
93
|
-
|
|
96
|
+
if args.command == "context":
|
|
97
|
+
ready = memory.initialized(args.project)
|
|
98
|
+
output({"status": "ready" if ready else "uninitialized", "initialized": ready, **result})
|
|
99
|
+
else:
|
|
100
|
+
output(result)
|
|
94
101
|
elif args.command == "export":
|
|
95
102
|
payload = {kind: memory.read(args.project, kind) for kind in memory.KINDS if kind != "errors"}
|
|
96
103
|
if args.public_safe:
|
|
97
104
|
for value in payload.values():
|
|
98
|
-
value["items"] = [item for item in value["items"] if item.get("scope")
|
|
105
|
+
value["items"] = [item for item in value["items"] if item.get("scope") == "project" and item.get("status") == "active"]
|
|
99
106
|
destination = Path(args.output)
|
|
100
107
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
101
108
|
destination.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate and validate a semantic palette role contract for a template.
|
|
3
|
+
|
|
4
|
+
A template exposes colour roles instead of raw hex values in markup. The
|
|
5
|
+
signature palette is the template's own; variants are derived from it by
|
|
6
|
+
moving hue while keeping lightness, capping accent saturation, and forcing
|
|
7
|
+
the contrast rules that make the roles safe to swap without editing markup.
|
|
8
|
+
The command is deterministic and makes no network or model calls.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import colorsys
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = "maggie-palette-roles-v1"
|
|
21
|
+
ROLES = ("canvas", "panel", "soft", "text", "accent", "accentText", "onAccent", "dark", "deep")
|
|
22
|
+
HEX = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
|
23
|
+
MIN_TEXT_CONTRAST = 4.5
|
|
24
|
+
DEFAULT_MAX_SATURATION = 0.65
|
|
25
|
+
DEFAULT_VARIANT_COUNT = 17
|
|
26
|
+
MIN_HUE_DISTANCE = 15.0
|
|
27
|
+
|
|
28
|
+
# Named hue anchors used for variants. Order is the output order.
|
|
29
|
+
HUE_ANCHORS = (
|
|
30
|
+
("rose", "Rose", 350), ("coral", "Coral", 10), ("terracotta", "Terracotta", 20),
|
|
31
|
+
("amber", "Amber", 36), ("ochre", "Ochre", 45), ("olive", "Olive", 65),
|
|
32
|
+
("moss", "Moss", 90), ("sage", "Sage", 130), ("jade", "Jade", 155),
|
|
33
|
+
("teal", "Teal", 178), ("lagoon", "Lagoon", 190), ("ocean", "Ocean", 205),
|
|
34
|
+
("cobalt", "Cobalt", 220), ("indigo", "Indigo", 240), ("iris", "Iris", 258),
|
|
35
|
+
("violet", "Violet", 275), ("plum", "Plum", 300), ("orchid", "Orchid", 322),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_hex(value: str) -> tuple[float, float, float]:
|
|
40
|
+
if not isinstance(value, str) or not HEX.match(value):
|
|
41
|
+
raise ValueError(f"colour must be #RRGGBB: {value!r}")
|
|
42
|
+
return tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)) # type: ignore[return-value]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_hex(rgb: tuple[float, float, float]) -> str:
|
|
46
|
+
return "#" + "".join(f"{round(max(0.0, min(1.0, channel)) * 255):02X}" for channel in rgb)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_hsl(value: str) -> tuple[float, float, float]:
|
|
50
|
+
hue, lightness, saturation = colorsys.rgb_to_hls(*parse_hex(value))
|
|
51
|
+
return hue * 360, saturation, lightness
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_hsl(hue: float, saturation: float, lightness: float) -> str:
|
|
55
|
+
return to_hex(colorsys.hls_to_rgb((hue % 360) / 360, max(0.0, min(1.0, lightness)), max(0.0, min(1.0, saturation))))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def luminance(value: str) -> float:
|
|
59
|
+
def linear(channel: float) -> float:
|
|
60
|
+
return channel / 12.92 if channel <= 0.03928 else ((channel + 0.055) / 1.055) ** 2.4
|
|
61
|
+
red, green, blue = (linear(channel) for channel in parse_hex(value))
|
|
62
|
+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def contrast(first: str, second: str) -> float:
|
|
66
|
+
high, low = sorted((luminance(first), luminance(second)), reverse=True)
|
|
67
|
+
return (high + 0.05) / (low + 0.05)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def hue_distance(first: float, second: float) -> float:
|
|
71
|
+
delta = abs(first - second) % 360
|
|
72
|
+
return min(delta, 360 - delta)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mix(first: str, second: str, amount: float) -> str:
|
|
76
|
+
a, b = parse_hex(first), parse_hex(second)
|
|
77
|
+
return to_hex(tuple(x + (y - x) * amount for x, y in zip(a, b))) # type: ignore[arg-type]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ensure_contrast(colour: str, background: str, minimum: float = MIN_TEXT_CONTRAST) -> str:
|
|
81
|
+
"""Move only lightness, away from the background, until contrast passes."""
|
|
82
|
+
if contrast(colour, background) >= minimum:
|
|
83
|
+
return colour
|
|
84
|
+
hue, saturation, lightness = to_hsl(colour)
|
|
85
|
+
step = -0.01 if luminance(background) > 0.18 else 0.01
|
|
86
|
+
candidate = colour
|
|
87
|
+
for _ in range(101):
|
|
88
|
+
lightness = max(0.0, min(1.0, lightness + step))
|
|
89
|
+
candidate = from_hsl(hue, saturation, lightness)
|
|
90
|
+
if contrast(candidate, background) >= minimum:
|
|
91
|
+
return candidate
|
|
92
|
+
return candidate
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pick_on_accent(accent: str, candidates: list[str]) -> str:
|
|
96
|
+
return max(candidates, key=lambda candidate: (round(contrast(candidate, accent), 4), candidate))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def complete_roles(roles: dict[str, str]) -> dict[str, str]:
|
|
100
|
+
"""Fill derived roles for a signature; supplied roles are never changed."""
|
|
101
|
+
missing = [name for name in ("canvas", "text", "accent") if not roles.get(name)]
|
|
102
|
+
if missing:
|
|
103
|
+
raise ValueError("signature needs canvas, text and accent; missing " + ", ".join(missing))
|
|
104
|
+
for name, value in roles.items():
|
|
105
|
+
if name not in ROLES:
|
|
106
|
+
raise ValueError(f"unknown palette role: {name}")
|
|
107
|
+
parse_hex(value)
|
|
108
|
+
filled = {name: roles[name].upper() for name in roles}
|
|
109
|
+
canvas, text, accent = filled["canvas"], filled["text"], filled["accent"]
|
|
110
|
+
filled.setdefault("panel", mix(canvas, text, 0.04))
|
|
111
|
+
filled.setdefault("soft", mix(canvas, accent, 0.12))
|
|
112
|
+
# The dark band is the text colour on light canvases and a deeper canvas on dark ones.
|
|
113
|
+
if luminance(canvas) > 0.18:
|
|
114
|
+
filled.setdefault("dark", text)
|
|
115
|
+
else:
|
|
116
|
+
canvas_hue, canvas_saturation, canvas_lightness = to_hsl(canvas)
|
|
117
|
+
filled.setdefault("dark", from_hsl(canvas_hue, canvas_saturation, canvas_lightness * 0.8))
|
|
118
|
+
deep_hue, deep_saturation, deep_lightness = to_hsl(filled["dark"])
|
|
119
|
+
filled.setdefault("deep", from_hsl(deep_hue, deep_saturation, deep_lightness * 0.7))
|
|
120
|
+
filled.setdefault("accentText", ensure_contrast(accent, canvas))
|
|
121
|
+
filled.setdefault("onAccent", pick_on_accent(accent, ["#FFFFFF", filled["deep"], text, canvas]))
|
|
122
|
+
return {name: filled[name] for name in ROLES}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def derive_variant(signature: dict[str, str], anchor: tuple[str, str, int], max_saturation: float) -> dict[str, object]:
|
|
126
|
+
identifier, name, target = anchor
|
|
127
|
+
accent_hue, accent_saturation, accent_lightness = to_hsl(signature["accent"])
|
|
128
|
+
shift = target - accent_hue
|
|
129
|
+
roles: dict[str, str] = {}
|
|
130
|
+
for role in ("canvas", "panel", "soft", "text", "dark", "deep"):
|
|
131
|
+
hue, saturation, lightness = to_hsl(signature[role])
|
|
132
|
+
# Neutrals follow the hue move but never gain chroma.
|
|
133
|
+
roles[role] = from_hsl(hue + shift, min(saturation, max_saturation), lightness)
|
|
134
|
+
roles["text"] = ensure_contrast(roles["text"], roles["canvas"])
|
|
135
|
+
roles["text"] = ensure_contrast(roles["text"], roles["panel"])
|
|
136
|
+
accent = from_hsl(target, min(accent_saturation, max_saturation), accent_lightness)
|
|
137
|
+
candidates = ["#FFFFFF", roles["deep"], roles["text"], roles["canvas"]]
|
|
138
|
+
on_accent = pick_on_accent(accent, candidates)
|
|
139
|
+
if contrast(on_accent, accent) < MIN_TEXT_CONTRAST:
|
|
140
|
+
accent = ensure_contrast(accent, on_accent)
|
|
141
|
+
on_accent = pick_on_accent(accent, candidates)
|
|
142
|
+
roles["accent"] = accent
|
|
143
|
+
roles["onAccent"] = on_accent
|
|
144
|
+
roles["accentText"] = ensure_contrast(accent, roles["canvas"])
|
|
145
|
+
return {"id": identifier, "name": name, "roles": {role: roles[role] for role in ROLES}}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def generate(roles: dict[str, str], *, template: str | None = None, name: str = "Signature",
|
|
149
|
+
count: int = DEFAULT_VARIANT_COUNT, max_saturation: float = DEFAULT_MAX_SATURATION) -> dict[str, object]:
|
|
150
|
+
if not 0 < max_saturation <= 1:
|
|
151
|
+
raise ValueError("max saturation must be in (0, 1]")
|
|
152
|
+
if count < 0:
|
|
153
|
+
raise ValueError("count must not be negative")
|
|
154
|
+
signature = complete_roles(roles)
|
|
155
|
+
accent_hue = to_hsl(signature["accent"])[0]
|
|
156
|
+
anchors = [anchor for anchor in HUE_ANCHORS if hue_distance(anchor[2], accent_hue) >= MIN_HUE_DISTANCE]
|
|
157
|
+
variants = [derive_variant(signature, anchor, max_saturation) for anchor in anchors[:count]]
|
|
158
|
+
return {
|
|
159
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
160
|
+
"template": template,
|
|
161
|
+
"signature": {"id": "signature", "name": name, "roles": signature},
|
|
162
|
+
"variants": variants,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def validate(palette: dict[str, object], max_saturation: float = DEFAULT_MAX_SATURATION) -> dict[str, object]:
|
|
167
|
+
errors: list[str] = []
|
|
168
|
+
warnings: list[str] = []
|
|
169
|
+
if palette.get("schemaVersion") != SCHEMA_VERSION:
|
|
170
|
+
errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
|
|
171
|
+
template = palette.get("template")
|
|
172
|
+
if template is not None and not isinstance(template, str):
|
|
173
|
+
errors.append("template must be a string or null")
|
|
174
|
+
entries: list[tuple[dict, bool]] = []
|
|
175
|
+
if isinstance(palette.get("signature"), dict):
|
|
176
|
+
entries.append((palette["signature"], False)) # type: ignore[arg-type]
|
|
177
|
+
else:
|
|
178
|
+
errors.append("signature must be an object")
|
|
179
|
+
if isinstance(palette.get("variants"), list):
|
|
180
|
+
entries.extend((variant, True) for variant in palette["variants"]) # type: ignore[union-attr]
|
|
181
|
+
else:
|
|
182
|
+
errors.append("variants must be a list")
|
|
183
|
+
seen: set[str] = set()
|
|
184
|
+
checked = 0
|
|
185
|
+
for entry, is_variant in entries:
|
|
186
|
+
if not isinstance(entry, dict):
|
|
187
|
+
errors.append("each palette entry must be an object")
|
|
188
|
+
continue
|
|
189
|
+
identifier = str(entry.get("id") or "")
|
|
190
|
+
label = identifier or "<missing id>"
|
|
191
|
+
if not identifier or not str(entry.get("name") or "").strip():
|
|
192
|
+
errors.append(f"{label}: id and name are required")
|
|
193
|
+
if identifier in seen:
|
|
194
|
+
errors.append(f"{label}: duplicate palette id")
|
|
195
|
+
seen.add(identifier)
|
|
196
|
+
roles = entry.get("roles")
|
|
197
|
+
if not isinstance(roles, dict):
|
|
198
|
+
errors.append(f"{label}: roles must be an object")
|
|
199
|
+
continue
|
|
200
|
+
missing = [role for role in ROLES if role not in roles]
|
|
201
|
+
invalid = [role for role in ROLES if role in roles and not (isinstance(roles[role], str) and HEX.match(roles[role]))]
|
|
202
|
+
if missing:
|
|
203
|
+
errors.append(f"{label}: missing roles " + ", ".join(missing))
|
|
204
|
+
if invalid:
|
|
205
|
+
errors.append(f"{label}: roles must be #RRGGBB: " + ", ".join(invalid))
|
|
206
|
+
if missing or invalid:
|
|
207
|
+
continue
|
|
208
|
+
checked += 1
|
|
209
|
+
for foreground, background in (("text", "canvas"), ("text", "panel"), ("accentText", "canvas"), ("onAccent", "accent")):
|
|
210
|
+
ratio = contrast(roles[foreground], roles[background])
|
|
211
|
+
if ratio < MIN_TEXT_CONTRAST:
|
|
212
|
+
errors.append(f"{label}: {foreground} on {background} is {ratio:.2f}:1; needs {MIN_TEXT_CONTRAST}:1")
|
|
213
|
+
saturation = to_hsl(roles["accent"])[1]
|
|
214
|
+
if is_variant and saturation > max_saturation + 0.01:
|
|
215
|
+
errors.append(f"{label}: accent saturation {saturation:.2f} exceeds cap {max_saturation:.2f}")
|
|
216
|
+
if contrast(roles["text"], roles["soft"]) < MIN_TEXT_CONTRAST:
|
|
217
|
+
warnings.append(f"{label}: text on soft is below {MIN_TEXT_CONTRAST}:1; avoid body copy on soft surfaces")
|
|
218
|
+
return {"schemaVersion": "maggie-palette-validation.v1", "passed": not errors, "checkedPalettes": checked, "errors": errors, "warnings": warnings}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _load_roles(args: argparse.Namespace) -> dict[str, str]:
|
|
222
|
+
roles: dict[str, str] = {}
|
|
223
|
+
if args.roles:
|
|
224
|
+
data = json.loads(args.roles.read_text(encoding="utf-8"))
|
|
225
|
+
if isinstance(data.get("signature"), dict):
|
|
226
|
+
data = data["signature"].get("roles") or {}
|
|
227
|
+
elif isinstance(data.get("roles"), dict):
|
|
228
|
+
data = data["roles"]
|
|
229
|
+
roles.update({key: value for key, value in data.items() if isinstance(value, str)})
|
|
230
|
+
for role in ROLES:
|
|
231
|
+
value = getattr(args, role, None)
|
|
232
|
+
if value:
|
|
233
|
+
roles[role] = value
|
|
234
|
+
return roles
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _emit(payload: dict[str, object], output: Path | None) -> None:
|
|
238
|
+
encoded = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
|
239
|
+
if output:
|
|
240
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
output.write_text(encoded, encoding="utf-8")
|
|
242
|
+
print(encoded, end="")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main(argv: list[str] | None = None) -> int:
|
|
246
|
+
parser = argparse.ArgumentParser(prog="maggie design palette", description=__doc__.splitlines()[0])
|
|
247
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
248
|
+
generate_parser = sub.add_parser("generate", help="derive contrast-safe variants from a signature palette")
|
|
249
|
+
generate_parser.add_argument("--roles", type=Path, help="JSON roles object, or an existing palette file")
|
|
250
|
+
for role in ROLES:
|
|
251
|
+
generate_parser.add_argument(f"--{role}", dest=role, help=f"{role} colour as #RRGGBB")
|
|
252
|
+
generate_parser.add_argument("--template", help="template id recorded in the contract")
|
|
253
|
+
generate_parser.add_argument("--name", default="Signature", help="signature palette display name")
|
|
254
|
+
generate_parser.add_argument("--count", type=int, default=DEFAULT_VARIANT_COUNT)
|
|
255
|
+
generate_parser.add_argument("--max-saturation", type=float, default=DEFAULT_MAX_SATURATION)
|
|
256
|
+
generate_parser.add_argument("--output", type=Path)
|
|
257
|
+
validate_parser = sub.add_parser("validate", help="check roles, hex values, contrast and saturation cap")
|
|
258
|
+
validate_parser.add_argument("--palette", type=Path, required=True)
|
|
259
|
+
validate_parser.add_argument("--max-saturation", type=float, default=DEFAULT_MAX_SATURATION)
|
|
260
|
+
args = parser.parse_args(argv)
|
|
261
|
+
try:
|
|
262
|
+
if args.command == "generate":
|
|
263
|
+
palette = generate(_load_roles(args), template=args.template, name=args.name, count=args.count, max_saturation=args.max_saturation)
|
|
264
|
+
report = validate(palette, args.max_saturation)
|
|
265
|
+
if not report["passed"]:
|
|
266
|
+
print(json.dumps(report, indent=2), file=sys.stderr)
|
|
267
|
+
print("BLOCKED: maggie-design palette generate: the signature palette fails the contrast contract", file=sys.stderr)
|
|
268
|
+
return 1
|
|
269
|
+
_emit(palette, args.output)
|
|
270
|
+
return 0
|
|
271
|
+
report = validate(json.loads(args.palette.read_text(encoding="utf-8")), args.max_saturation)
|
|
272
|
+
_emit(report, None)
|
|
273
|
+
return 0 if report["passed"] else 1
|
|
274
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
275
|
+
print(f"BLOCKED: maggie-design palette {args.command}: {error}", file=sys.stderr)
|
|
276
|
+
return 1
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
raise SystemExit(main())
|