@mmerterden/multi-agent-pipeline 14.2.1 → 14.2.2

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 (106) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +10 -6
  3. package/README.tr.md +143 -0
  4. package/docs/architecture.md +23 -8
  5. package/docs/ecosystem.md +237 -0
  6. package/install/_plugin-skills.mjs +16 -2
  7. package/install/codex.mjs +9 -4
  8. package/install/templates/copilot-instructions.md +12 -9
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/SKILL.md +2 -2
  11. package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
  12. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  13. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
  14. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
  15. package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
  16. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
  17. package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
  18. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
  19. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
  20. package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
  21. package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
  22. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
  23. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  24. package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
  25. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  26. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  27. package/pipeline/lib/extract-conventions.sh +44 -15
  28. package/pipeline/lib/fetch-figma-annotations.sh +8 -1
  29. package/pipeline/lib/fetch-fortify.sh +23 -8
  30. package/pipeline/lib/figma-screenshot.sh +11 -1
  31. package/pipeline/lib/issue-fetcher.sh +76 -9
  32. package/pipeline/lib/md2confluence-v3.py +16 -2
  33. package/pipeline/lib/plan-todos.sh +5 -2
  34. package/pipeline/lib/post-pr-review.sh +8 -6
  35. package/pipeline/lib/shadow-git.sh +50 -9
  36. package/pipeline/lib/submodule-detector.sh +8 -1
  37. package/pipeline/multi-agent-refs/_input-parser.md +1 -1
  38. package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
  39. package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
  40. package/pipeline/multi-agent-refs/channels/jira.md +13 -2
  41. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
  42. package/pipeline/multi-agent-refs/channels/pr.md +20 -0
  43. package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
  44. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  45. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  46. package/pipeline/multi-agent-refs/generate-issue.md +2 -2
  47. package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
  48. package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
  49. package/pipeline/multi-agent-refs/phases/modes.md +20 -0
  50. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  51. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
  52. package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
  53. package/pipeline/multi-agent-refs/phases.md +6 -0
  54. package/pipeline/multi-agent-refs/rules.md +2 -0
  55. package/pipeline/schemas/prefs.schema.json +1 -1
  56. package/pipeline/scripts/audit-log-rotate.sh +10 -0
  57. package/pipeline/scripts/build-stack-plugins.mjs +8 -1
  58. package/pipeline/scripts/check-derived-drift.mjs +13 -1
  59. package/pipeline/scripts/diff-explain.mjs +41 -3
  60. package/pipeline/scripts/diff-risk-score.mjs +72 -8
  61. package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
  62. package/pipeline/scripts/learning-curve.mjs +8 -2
  63. package/pipeline/scripts/output-quality-check.sh +15 -4
  64. package/pipeline/scripts/phase-tracker.sh +21 -8
  65. package/pipeline/scripts/pre-commit-check.sh +69 -22
  66. package/pipeline/scripts/render-agent-log-cost.sh +8 -3
  67. package/pipeline/scripts/render-cost-summary.sh +42 -22
  68. package/pipeline/scripts/render-work-summary.sh +47 -13
  69. package/pipeline/scripts/review-scope.mjs +1 -1
  70. package/pipeline/scripts/run-aggregator.mjs +38 -14
  71. package/pipeline/scripts/smoke-schema-validation.sh +5 -1
  72. package/pipeline/scripts/test-gap-scan.mjs +45 -6
  73. package/pipeline/scripts/uninstall.mjs +39 -4
  74. package/pipeline/scripts/update-issue-progress.sh +12 -16
  75. package/pipeline/scripts/worktree-finalize.sh +23 -2
  76. package/pipeline/skills/.skills-index.json +11 -2
  77. package/pipeline/skills/shared/README.md +1 -1
  78. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
  79. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  80. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
  81. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
  82. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
  83. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
  84. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
  85. package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
  86. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
  87. package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
  88. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  89. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
  90. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
  91. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
  92. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
  93. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
  94. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
  95. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
  96. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
  97. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
  98. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
  99. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
  100. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
  101. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
  102. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
  103. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
  104. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
  105. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
  106. package/pipeline/skills/skills-index.md +3 -2
@@ -0,0 +1,529 @@
1
+ #!/usr/bin/env python3
2
+ """Render a "which key ↔ which UI element" overlay image for one screen.
3
+
4
+ Figma's own image render does NOT include the Dev Mode annotation overlay, so this
5
+ RECONSTRUCTS it deterministically: the screen image (REST render or an MCP-supplied
6
+ data URL) on the right, one card per keyable text element on the left, each card
7
+ tying the **localization key** to the element and showing the content team's
8
+ **CMS TR/EN** copy where annotated. No AI image generation - pure geometry + HTML +
9
+ headless Chrome (with an `.html` fallback when no Chrome is present).
10
+
11
+ Two input modes (mirrors fetch-annotations.py's REST/MCP split):
12
+ * REST (default) - needs a Figma token (--token / env / keychain FIGMA_ACCESS_TOKEN):
13
+ geometry `GET /v1/files/<key>/nodes?ids=<id>` → absoluteBoundingBox
14
+ image `GET /v1/images/<key>?ids=<id>&format=png&scale=<n>` → PNG url → data URL
15
+ * --spec <file> (MCP / no network): { fileKey, pages:[{nodeId,name,image:<dataUrl>,
16
+ frame:{w,h}, nodes:[{characters,tr,en,x,y,w,h}] }] } - the orchestrator builds
17
+ this from get_screenshot + use_figma so no REST calls happen.
18
+
19
+ Cards are keyed off the run's **mapping JSON** (`--mapping`): each text node is matched to
20
+ a mapping row (by `cmsNodeId`, else by normalized value) to label the card with its
21
+ `newKey`. A node carrying a Final UX Writing annotation → pink "İçerik (CMS)" card with
22
+ TR/EN; a keyable node without one → gray "Öneri bekleniyor" card with the on-screen text.
23
+ Non-keyable text (no key, no annotation) is dropped.
24
+
25
+ **No annotations needed.** When value-matching is unreliable (design wording ≠ resolved
26
+ values, or the frame mixes in background content), anchor explicitly: set each row's
27
+ `cmsNodeId` to a synthetic id and feed a `--spec` whose nodes carry the same `id` + the
28
+ box read off the clean screenshot - `cmsNodeId` matching is exact, so the overlay is
29
+ faithful with zero annotations. (`design-export/.../tree.json` is component-instance level
30
+ - use its root `nodeId` for the REST frame, not for per-label boxes.)
31
+
32
+ Output: `<out>/<slug>.overlay.png` (or `<slug>.overlay.html` fallback) + a sibling
33
+ `<slug>.overlay.manifest.json` (`page → file → {fileKey,nodeId}`) so the publish step
34
+ can find the image to attach. Read-only against every source.
35
+ """
36
+ import argparse
37
+ import base64
38
+ import json
39
+ import os
40
+ import re
41
+ import ssl
42
+ import subprocess
43
+ import sys
44
+ import tempfile
45
+ import unicodedata
46
+ import urllib.error
47
+ import urllib.parse
48
+ import urllib.request
49
+
50
+ FIGMA_API = "https://api.figma.com/v1"
51
+ OUT_SUFFIX = ".overlay.png"
52
+
53
+ # --- card layout constants (ported from web render-annotations.mjs) --------
54
+ CARD_W = 320
55
+ COL_X = 24
56
+ CHANNEL = 190
57
+ DESIGN_X = COL_X + CARD_W + CHANNEL
58
+ RIGHT_MARGIN = 40
59
+ TOP = 60
60
+ CARD_H = 116
61
+ CARD_GAP = 14
62
+ PINK = "#EC1E91"
63
+ GRAY = "#9aa0a6"
64
+
65
+ # Annotation authors separate label from value with a colon, a hyphen, or the
66
+ # en-dash their editor auto-inserts. Built from the codepoint so this file
67
+ # carries no literal dash variant of its own.
68
+ _DASHES = ':\\-' + chr(0x2013)
69
+ RE_TR = re.compile(rf"^(?:tr|tr[kc]e|t[üu]rk[çc]e)\s*[{_DASHES}]\s*(.+)$", re.I)
70
+ RE_EN = re.compile(rf"^(?:en|eng|ing|[iİ]ngilizce)\s*[{_DASHES}]\s*(.+)$", re.I)
71
+
72
+
73
+ def split_lang(raw):
74
+ if not raw:
75
+ return {"tr": None, "en": None}
76
+ lines = [ln.strip() for ln in re.split(r"\r?\n", raw) if ln.strip()]
77
+ tr = en = None
78
+ bare = []
79
+ for ln in lines:
80
+ m = RE_TR.match(ln)
81
+ if m and tr is None:
82
+ tr = m.group(1).strip()
83
+ continue
84
+ m = RE_EN.match(ln)
85
+ if m and en is None:
86
+ en = m.group(1).strip()
87
+ continue
88
+ bare.append(ln)
89
+ if tr is not None or en is not None:
90
+ bi = 0
91
+ if tr is None and bi < len(bare):
92
+ tr = bare[bi]; bi += 1
93
+ if en is None and bi < len(bare):
94
+ en = bare[bi]; bi += 1
95
+ return {"tr": tr, "en": en}
96
+ if len(bare) >= 2:
97
+ return {"tr": bare[0], "en": bare[1]}
98
+ if len(bare) == 1:
99
+ return {"tr": bare[0], "en": None}
100
+ return {"tr": None, "en": None}
101
+
102
+
103
+ def canon_id(s):
104
+ return re.sub(r"^(\d+)-(\d+)$", r"\1:\2", (s or "").strip())
105
+
106
+
107
+ def norm(s):
108
+ """NFC → trim → collapse whitespace → strip trailing .:... → lower (web `norm`)."""
109
+ if not s:
110
+ return ""
111
+ s = unicodedata.normalize("NFC", s).strip()
112
+ s = re.sub(r"\s+", " ", s)
113
+ s = re.sub(r"[.:...]+$", "", s).strip()
114
+ return s.lower()
115
+
116
+
117
+ def slug(s):
118
+ s = (s or "screen").strip()
119
+ s = re.sub(r'[/\\:*?"<>|]+', "-", s)
120
+ s = re.sub(r"\s+", "-", s)
121
+ s = re.sub(r"-{2,}", "-", s)
122
+ return s.strip("-").lower() or "screen"
123
+
124
+
125
+ def esc(s):
126
+ return (str(s) if s is not None else "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
127
+
128
+
129
+ # --- Figma token + HTTP (self-contained, mirrors fetch-annotations.py) ------
130
+ def figma_token(args):
131
+ if args.token:
132
+ return args.token
133
+ for env in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN"):
134
+ v = os.environ.get(env)
135
+ if v:
136
+ return v.strip()
137
+ try:
138
+ r = subprocess.run(
139
+ ["security", "find-generic-password", "-s", "FIGMA_ACCESS_TOKEN", "-w"],
140
+ capture_output=True, text=True)
141
+ if r.returncode == 0 and r.stdout.strip():
142
+ return r.stdout.strip()
143
+ except Exception:
144
+ pass
145
+ return None
146
+
147
+
148
+ def _ctx(ca_file):
149
+ return ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
150
+
151
+
152
+ def http_get_json(url, headers, ca_file):
153
+ req = urllib.request.Request(url, headers=headers)
154
+ try:
155
+ with urllib.request.urlopen(req, context=_ctx(ca_file), timeout=60) as r:
156
+ return json.load(r)
157
+ except urllib.error.HTTPError as e:
158
+ if e.code == 429:
159
+ sys.exit("Figma 429 (rate-limited) - re-run with --spec (MCP path) instead.")
160
+ sys.exit(f"Figma HTTP {e.code} for {url}: {e.read()[:200]!r}")
161
+ except urllib.error.URLError as e:
162
+ sys.exit(f"Figma request failed: {getattr(e, 'reason', e)}")
163
+
164
+
165
+ def http_get_bytes(url, ca_file):
166
+ try:
167
+ with urllib.request.urlopen(url, context=_ctx(ca_file), timeout=120) as r:
168
+ return r.read()
169
+ except Exception as e:
170
+ sys.exit(f"image fetch failed: {e}")
171
+
172
+
173
+ # --- node collection (REST) ------------------------------------------------
174
+ def node_anno(node):
175
+ anns = node.get("annotations")
176
+ if not isinstance(anns, list):
177
+ return {"tr": None, "en": None}
178
+ for a in anns:
179
+ if not isinstance(a, dict):
180
+ continue
181
+ lbl = a.get("label") or a.get("labelMarkdown") or ""
182
+ if lbl:
183
+ p = split_lang(lbl)
184
+ if p["tr"] is not None or p["en"] is not None:
185
+ return p
186
+ return {"tr": None, "en": None}
187
+
188
+
189
+ def collect_rest_nodes(file_key, node_id, token, ca_file, scale):
190
+ headers = {"X-Figma-Token": token}
191
+ data = http_get_json(f"{FIGMA_API}/files/{file_key}/nodes?ids={urllib.parse.quote(node_id, safe='')}",
192
+ headers, ca_file)
193
+ entry = (data.get("nodes") or {}).get(node_id) or {}
194
+ doc = entry.get("document")
195
+ if not doc:
196
+ sys.exit(f"node {node_id} not found in file {file_key}")
197
+ fb = doc.get("absoluteBoundingBox") or {"x": 0, "y": 0, "width": 0, "height": 0}
198
+ frame = {"w": round(fb.get("width", 0)), "h": round(fb.get("height", 0))}
199
+ nodes = []
200
+
201
+ def walk(n):
202
+ b = n.get("absoluteBoundingBox")
203
+ is_text = n.get("type") == "TEXT" and isinstance(n.get("characters"), str) and n["characters"].strip()
204
+ ann = node_anno(n)
205
+ if b and (is_text or ann["tr"] is not None or ann["en"] is not None):
206
+ nodes.append({
207
+ "id": n.get("id"),
208
+ "characters": n.get("characters") if is_text else "",
209
+ "tr": ann["tr"], "en": ann["en"],
210
+ "x": round(b["x"] - fb["x"]), "y": round(b["y"] - fb["y"]),
211
+ "w": round(b["width"]), "h": round(b["height"]),
212
+ })
213
+ for c in n.get("children", []) or []:
214
+ walk(c)
215
+
216
+ walk(doc)
217
+
218
+ # image render → data URL
219
+ img = http_get_json(
220
+ f"{FIGMA_API}/images/{file_key}?ids={urllib.parse.quote(node_id, safe='')}&format=png&scale={scale}",
221
+ headers, ca_file)
222
+ url = (img.get("images") or {}).get(node_id)
223
+ if not url:
224
+ sys.exit(f"no image render returned for {node_id}")
225
+ raw = http_get_bytes(url, ca_file)
226
+ data_url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
227
+ return {"nodeId": node_id, "image": data_url, "frame": frame, "nodes": nodes}
228
+
229
+
230
+ # --- card model ------------------------------------------------------------
231
+ def build_index(rows):
232
+ by_node, by_val = {}, {}
233
+ for r in rows:
234
+ key = r.get("newKey")
235
+ if not key:
236
+ continue
237
+ nid = r.get("cmsNodeId")
238
+ if nid:
239
+ by_node[canon_id(nid)] = r
240
+ # index every known value (tr + en across new / cms / legacy) so a node
241
+ # matches its key regardless of the frame's language or wording source
242
+ for src in (r.get("new") or {}, r.get("cms") or {}, r.get("legacy") or {}):
243
+ if isinstance(src, dict):
244
+ for lang in ("tr", "en"):
245
+ v = src.get(lang)
246
+ if v:
247
+ by_val.setdefault(norm(v), r)
248
+ if r.get("element"):
249
+ by_val.setdefault(norm(r["element"]), r)
250
+ return by_node, by_val
251
+
252
+
253
+ def match_row(node, by_node, by_val):
254
+ nid = canon_id(node.get("id") or "")
255
+ if nid in by_node:
256
+ return by_node[nid]
257
+ for v in (node.get("tr"), node.get("characters")):
258
+ if v and norm(v) in by_val:
259
+ return by_val[norm(v)]
260
+ return None
261
+
262
+
263
+ def build_cards(page, rows):
264
+ by_node, by_val = build_index(rows)
265
+ # 1-based index of every row in the mapping → the SAME number build-artifact prints
266
+ # in the summary/detail tables (both enumerate rows in order). The overlay card badge
267
+ # uses it so a mark on the screen and its table row always carry the same number.
268
+ row_num = {id(r): i for i, r in enumerate(rows, 1)}
269
+ cards = []
270
+ for n in page["nodes"]:
271
+ row = match_row(n, by_node, by_val)
272
+ key = row.get("newKey") if row else None
273
+ # CMS values: prefer the node's own annotation, else the matched row's cms.*
274
+ tr = n.get("tr")
275
+ en = n.get("en")
276
+ if tr is None and en is None and row and isinstance(row.get("cms"), dict):
277
+ tr, en = row["cms"].get("tr"), row["cms"].get("en")
278
+ if not key and tr is None and en is None:
279
+ continue # non-keyable
280
+ cards.append({
281
+ "key": key or "(eşleşmedi)",
282
+ "tr": tr, "en": en,
283
+ "designText": n.get("characters") or "",
284
+ "x": n["x"], "y": n["y"], "w": n["w"], "h": n["h"],
285
+ "annotated": tr is not None or en is not None,
286
+ "side": (n.get("side") or "left").lower(),
287
+ "num": row_num.get(id(row)) if row else None, # table row number (or None → sequential)
288
+ })
289
+ cards.sort(key=lambda c: c["y"])
290
+ return cards
291
+
292
+
293
+ # --- HTML composition ------------------------------------------------------
294
+ def build_html(page, cards, title):
295
+ """Lay out cards on EITHER side of the frame (per-card `side`: left|right). Cards
296
+ that don't read well on the left (e.g. right-column UI like the amenity grid's
297
+ right entries) are placed on the right so their connector stays short and doesn't
298
+ cross the whole frame. Each column is ordered by target y and greedily stacked;
299
+ each link gets its own lane and a numbered dot matching its card. Returns
300
+ (html, canvas_w, canvas_h)."""
301
+ frame_w = page["frame"]["w"] or 800
302
+ frame_h = page["frame"]["h"] or 600
303
+ left_card_right = COL_X + CARD_W
304
+ design_right = DESIGN_X + frame_w
305
+ right_col_x = design_right + CHANNEL
306
+
307
+ items = []
308
+ for c in cards:
309
+ tx = DESIGN_X + c["x"] + c["w"] / 2
310
+ ty = TOP + c["y"] + c["h"] / 2
311
+ items.append({"tx": tx, "ty": ty, "c": c, "side": c.get("side", "left")})
312
+
313
+ left = sorted([it for it in items if it["side"] != "right"], key=lambda it: it["ty"])
314
+ right = sorted([it for it in items if it["side"] == "right"], key=lambda it: it["ty"])
315
+ has_right = bool(right)
316
+
317
+ def place(col):
318
+ cursor = TOP
319
+ for it in col:
320
+ it["y"] = max(cursor, it["ty"] - CARD_H / 2, TOP)
321
+ cursor = it["y"] + CARD_H + CARD_GAP
322
+ return cursor
323
+ cur_l, cur_r = place(left), place(right)
324
+
325
+ canvas_w = (right_col_x + CARD_W + RIGHT_MARGIN) if has_right else (design_right + RIGHT_MARGIN)
326
+ canvas_h = max(TOP + frame_h + TOP, cur_l + TOP, cur_r + TOP)
327
+
328
+ # numbering: use the matched row's table number so the mark on the screen and its
329
+ # table row agree; fall back to sequential only for a card with no matched row.
330
+ seq = 1
331
+ for it in left + right:
332
+ n = it["c"].get("num")
333
+ if n is None:
334
+ n = seq
335
+ it["num"] = n
336
+ seq += 1
337
+
338
+ nL, nR = len(left), len(right)
339
+ lane_step_l = (CHANNEL - 30) / max(1, nL - 1) if nL > 1 else 0
340
+ lane_step_r = (CHANNEL - 30) / max(1, nR - 1) if nR > 1 else 0
341
+
342
+ connectors, card_html = [], []
343
+
344
+ def emit(it, idx, side):
345
+ c, tx, ty, y, num = it["c"], it["tx"], it["ty"], it["y"], it["num"]
346
+ card_mid = y + CARD_H / 2
347
+ colour = PINK if c["annotated"] else GRAY
348
+ if side == "right":
349
+ lane = design_right + 16 + idx * lane_step_r
350
+ path = f'M {right_col_x} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
351
+ left_px = right_col_x
352
+ else:
353
+ lane = left_card_right + 16 + idx * lane_step_l
354
+ path = f'M {left_card_right} {card_mid:.0f} H {lane:.0f} V {ty:.0f} H {tx:.0f}'
355
+ left_px = COL_X
356
+ connectors.append(
357
+ f'<path d="{path}" '
358
+ f'stroke="{colour}" stroke-width="1.4" stroke-dasharray="5 4" fill="none" opacity="0.85"/>'
359
+ f'<circle cx="{tx:.0f}" cy="{ty:.0f}" r="9" fill="{colour}"/>'
360
+ f'<text x="{tx:.0f}" y="{ty + 3.5:.0f}" text-anchor="middle" font-size="11" '
361
+ f'font-weight="700" fill="#fff">{num}</text>')
362
+ badge = f'<span class="num" style="background:{colour}">{num}</span>'
363
+ if c["annotated"]:
364
+ pill = f'<span class="pill" style="background:{PINK}">İçerik (CMS)</span>'
365
+ rows = f'<div class="row"><span class="lang">TR:</span> {esc(c["tr"]) if c["tr"] else "-"}</div>'
366
+ if c["en"] is not None:
367
+ rows += f'<div class="row"><span class="lang">EN:</span> {esc(c["en"])}</div>'
368
+ klass = ""
369
+ else:
370
+ pill = f'<span class="pill" style="background:{GRAY}">Öneri bekleniyor</span>'
371
+ rows = f'<div class="row muted">metin: {esc(c["designText"]) or "-"}</div>'
372
+ klass = " keyonly"
373
+ card_html.append(
374
+ f'<div class="card{klass}" style="left:{left_px:.0f}px;top:{y:.0f}px">'
375
+ f'<div class="hd">{badge}{pill}</div>'
376
+ f'<div class="key">{esc(c["key"])}</div>{rows}</div>')
377
+
378
+ for i, it in enumerate(left):
379
+ emit(it, i, "left")
380
+ for i, it in enumerate(right):
381
+ emit(it, i, "right")
382
+
383
+ html = f"""<!doctype html><html><head><meta charset="utf-8"><style>
384
+ * {{ box-sizing:border-box; }}
385
+ body {{ margin:0; background:#1e1e1e; font-family:-apple-system,'Segoe UI',Roboto,Arial,sans-serif; }}
386
+ .canvas {{ position:relative; width:{canvas_w}px; height:{canvas_h:.0f}px; }}
387
+ .design {{ position:absolute; left:{DESIGN_X}px; top:{TOP}px; width:{frame_w}px; height:{frame_h}px;
388
+ box-shadow:0 0 0 1px #333; }}
389
+ svg.links {{ position:absolute; left:0; top:0; width:{canvas_w}px; height:{canvas_h:.0f}px; pointer-events:none; }}
390
+ .card {{ position:absolute; width:{CARD_W}px; min-height:{CARD_H}px; background:#2b2b2b; color:#e8e8e8;
391
+ border-radius:10px; padding:10px 14px; box-shadow:0 1px 4px rgba(0,0,0,.4); }}
392
+ .card.keyonly {{ background:#f4f5f6; color:#202124; }}
393
+ .hd {{ display:flex; align-items:center; gap:7px; margin-bottom:6px; }}
394
+ .num {{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
395
+ border-radius:50%; color:#fff; font-size:11px; font-weight:700; flex:none; }}
396
+ .pill {{ display:inline-block; color:#fff; font-size:11px; font-weight:600; padding:2px 8px;
397
+ border-radius:10px; }}
398
+ .key {{ font-family:'SF Mono',Menlo,Consolas,monospace; font-size:12px; color:#9ad; margin-bottom:6px;
399
+ word-break:break-all; }}
400
+ .card.keyonly .key {{ color:#1666c8; }}
401
+ .row {{ font-size:13px; line-height:1.35; }}
402
+ .row.muted {{ color:#5f6368; }}
403
+ .lang {{ color:{PINK}; font-weight:600; }}
404
+ .title {{ position:absolute; left:{COL_X}px; top:18px; color:#bbb; font-size:13px; }}
405
+ </style></head><body><div class="canvas">
406
+ <div class="title">{esc(title)}</div>
407
+ <img class="design" src="{page['image']}">
408
+ <svg class="links">{''.join(connectors)}</svg>
409
+ {''.join(card_html)}
410
+ </div></body></html>"""
411
+ return html, canvas_w, canvas_h
412
+
413
+
414
+ # --- rasterize -------------------------------------------------------------
415
+ def find_chrome():
416
+ for p in (
417
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
418
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
419
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
420
+ ):
421
+ if os.path.isfile(p) and os.access(p, os.X_OK):
422
+ return p
423
+ from shutil import which
424
+ for c in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
425
+ w = which(c)
426
+ if w:
427
+ return w
428
+ return None
429
+
430
+
431
+ def render_png(html, out_path, canvas_w, canvas_h, scale):
432
+ chrome = find_chrome()
433
+ if not chrome:
434
+ alt = out_path[:-4] + ".html" if out_path.endswith(".png") else out_path + ".html"
435
+ with open(alt, "w", encoding="utf-8") as fh:
436
+ fh.write(html)
437
+ sys.stderr.write(f"# no headless Chrome found - wrote {alt} (open/print to PDF)\n")
438
+ return alt
439
+ with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as tf:
440
+ tf.write(html)
441
+ tmp = tf.name
442
+ try:
443
+ r = subprocess.run(
444
+ [chrome, "--headless", "--disable-gpu", "--hide-scrollbars",
445
+ f"--force-device-scale-factor={scale}",
446
+ f"--window-size={canvas_w},{int(canvas_h)}",
447
+ f"--screenshot={out_path}", tmp],
448
+ capture_output=True, text=True, timeout=120)
449
+ if not os.path.isfile(out_path):
450
+ sys.exit(f"Chrome screenshot failed: {r.stderr[:300]}")
451
+ finally:
452
+ try:
453
+ os.unlink(tmp)
454
+ except OSError:
455
+ pass
456
+ return out_path
457
+
458
+
459
+ def main():
460
+ ap = argparse.ArgumentParser(description=__doc__,
461
+ formatter_class=argparse.RawDescriptionHelpFormatter)
462
+ ap.add_argument("--mapping", required=True, help="mapping JSON (rows + figmaFileKey/figmaNodes + screen)")
463
+ ap.add_argument("--spec", help="MCP spec JSON (no network)")
464
+ ap.add_argument("--file", help="Figma fileKey (REST; else from --mapping figmaFileKey)")
465
+ ap.add_argument("--nodes", help="node-ids (REST; else from --mapping figmaNodes)")
466
+ ap.add_argument("--scale", type=int, default=2)
467
+ ap.add_argument("--token")
468
+ ap.add_argument("--ca-file")
469
+ ap.add_argument("--out", default=".", help="output dir")
470
+ ap.add_argument("--slug", help="override slug (default from screen name)")
471
+ args = ap.parse_args()
472
+
473
+ with open(args.mapping, encoding="utf-8") as fh:
474
+ m = json.load(fh)
475
+ rows = m.get("rows") or []
476
+ screen = m.get("screen") or "screen"
477
+ sl = args.slug or slug(screen)
478
+ os.makedirs(args.out, exist_ok=True)
479
+
480
+ file_key = args.file or m.get("figmaFileKey")
481
+ node_ids = [n.strip() for n in (args.nodes or "").split(",") if n.strip()] or \
482
+ [str(n) for n in (m.get("figmaNodes") or [])]
483
+
484
+ # one overlay for the screen: use the FIRST node as the framing node (the screen frame)
485
+ if args.spec:
486
+ with open(args.spec, encoding="utf-8") as fh:
487
+ spec = json.load(fh)
488
+ pages = spec.get("pages") or []
489
+ if not pages:
490
+ sys.exit("--spec has no pages")
491
+ page = pages[0]
492
+ file_key = file_key or spec.get("fileKey")
493
+ else:
494
+ if not (file_key and node_ids):
495
+ ap.error("REST needs a fileKey + node-ids (via --file/--nodes or the mapping). "
496
+ "Or pass --spec for the MCP path.")
497
+ token = figma_token(args)
498
+ if not token:
499
+ sys.exit("No Figma token - pass --token, set FIGMA_ACCESS_TOKEN, or add the "
500
+ "keychain item FIGMA_ACCESS_TOKEN (or use --spec).")
501
+ page = collect_rest_nodes(file_key, canon_id(node_ids[0]), token, args.ca_file, args.scale)
502
+ page["name"] = screen
503
+
504
+ cards = build_cards(page, rows)
505
+ if not cards:
506
+ sys.stderr.write("# warning: no keyable cards built (no node matched a row / no annotations)\n")
507
+ html, canvas_w, canvas_h = build_html(page, cards, screen)
508
+ out_png = os.path.join(args.out, sl + OUT_SUFFIX)
509
+ written = render_png(html, out_png, canvas_w, canvas_h, args.scale)
510
+
511
+ manifest = {
512
+ "screen": screen,
513
+ "file": os.path.basename(written),
514
+ "fileKey": file_key,
515
+ "nodeId": canon_id(node_ids[0]) if node_ids else page.get("nodeId"),
516
+ "cardCount": len(cards),
517
+ "annotatedCount": sum(1 for c in cards if c["annotated"]),
518
+ "width": canvas_w, "height": int(canvas_h), "scale": args.scale,
519
+ }
520
+ mpath = os.path.join(args.out, sl + ".overlay.manifest.json")
521
+ with open(mpath, "w", encoding="utf-8") as fh:
522
+ json.dump(manifest, fh, ensure_ascii=False, indent=2)
523
+ fh.write("\n")
524
+ sys.stderr.write(f"# overlay: {len(cards)} card(s), {manifest['annotatedCount']} annotated → "
525
+ f"{written}\n")
526
+
527
+
528
+ if __name__ == "__main__":
529
+ main()