ui-mirror-skill 1.0.0

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.
@@ -0,0 +1,580 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ generate_migration.py — Transform a token diff into a prioritized migration plan.
4
+
5
+ Usage:
6
+ python3 generate_migration.py <token_diff.json>
7
+
8
+ Input: Token diff JSON file (output of compare_tokens.py)
9
+ Output: Markdown migration plan to stdout.
10
+
11
+ Dependencies: Python 3 standard library only.
12
+ """
13
+
14
+ import json
15
+ import sys
16
+ import re
17
+ from collections import OrderedDict
18
+ from datetime import date
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # 1. Complexity weights for effort estimation
23
+ # ---------------------------------------------------------------------------
24
+
25
+ COMPLEXITY_WEIGHTS = {
26
+ "colors": 1, # CSS variable change — lowest risk
27
+ "radius": 1, # CSS variable change
28
+ "shadows": 1, # CSS variable change
29
+ "typography": 2, # May require font import + multiple class changes
30
+ "spacing": 2, # Affects layout
31
+ "components": 3, # Requires per-component code changes
32
+ "layout": 4, # Structural changes — highest risk
33
+ }
34
+
35
+ # Effort labels
36
+ def effort_label(points: int) -> str:
37
+ if points <= 5:
38
+ return "Small (~30 min)"
39
+ if points <= 15:
40
+ return "Medium (~1-2 hr)"
41
+ if points <= 30:
42
+ return "Large (~3-5 hr)"
43
+ return "XL (~1 day+)"
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # 2. Phase 1: CSS Token Overrides
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def generate_phase1_css(diff: dict) -> tuple[str, int]:
51
+ """
52
+ Generate :root { } CSS with color/radius/shadow overrides.
53
+ Returns (css_block, change_count).
54
+ """
55
+ lines = []
56
+ count = 0
57
+
58
+ # --- Colors ---
59
+ color_mods = diff.get("colors", {}).get("modify", [])
60
+ color_adds = diff.get("colors", {}).get("add", [])
61
+
62
+ if color_mods or color_adds:
63
+ lines.append(" /* ── Colors ── */")
64
+ for item in color_mods:
65
+ name = item.get("name", "unknown")
66
+ bench = item.get("benchmark", "")
67
+ current = item.get("current", "")
68
+ delta = item.get("deltaE")
69
+ delta_str = " (deltaE: {:.3f})".format(delta) if delta is not None else ""
70
+ lines.append(" --{}: {}; /* was: {}{} */".format(name, bench, current, delta_str))
71
+ count += 1
72
+
73
+ for item in color_adds:
74
+ name = item.get("name", "unknown")
75
+ bench = item.get("benchmark", "")
76
+ lines.append(" --mirror-{}: {}; /* NEW from benchmark */".format(name, bench))
77
+ count += 1
78
+
79
+ # --- Radius ---
80
+ radius_mods = diff.get("radius", {}).get("modify", [])
81
+ radius_adds = diff.get("radius", {}).get("add", [])
82
+
83
+ if radius_mods or radius_adds:
84
+ lines.append("")
85
+ lines.append(" /* ── Border Radius ── */")
86
+ for item in radius_mods:
87
+ name = item.get("name", "unknown")
88
+ bench = _summarize(item.get("benchmark"))
89
+ current = _summarize(item.get("current"))
90
+ lines.append(" --radius-{}: {}px; /* was: {} */".format(
91
+ name, _extract_px(bench), current
92
+ ))
93
+ count += 1
94
+
95
+ for item in radius_adds:
96
+ name = item.get("name", "unknown")
97
+ bench = _summarize(item.get("benchmark"))
98
+ lines.append(" --radius-{}: {}px; /* NEW */".format(name, _extract_px(bench)))
99
+ count += 1
100
+
101
+ # --- Shadows ---
102
+ shadow_mods = diff.get("shadows", {}).get("modify", [])
103
+ shadow_adds = diff.get("shadows", {}).get("add", [])
104
+
105
+ if shadow_mods or shadow_adds:
106
+ lines.append("")
107
+ lines.append(" /* ── Shadows ── */")
108
+ for item in shadow_mods:
109
+ name = item.get("name", "unknown")
110
+ bench = _summarize(item.get("benchmark"))
111
+ current = _summarize(item.get("current"))
112
+ lines.append(" --shadow-{}: {}; /* was: {} */".format(name, bench, current))
113
+ count += 1
114
+
115
+ for item in shadow_adds:
116
+ name = item.get("name", "unknown")
117
+ bench = _summarize(item.get("benchmark"))
118
+ lines.append(" --shadow-{}: {}; /* NEW */".format(name, bench))
119
+ count += 1
120
+
121
+ if not lines:
122
+ return "", 0
123
+
124
+ css = ":root {\n" + "\n".join(lines) + "\n}"
125
+ return css, count
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # 3. Phase 2: Component Modifications
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def generate_phase2_components(diff: dict) -> tuple[list[dict], int]:
133
+ """
134
+ Generate component-level modification instructions.
135
+ Returns (list of component change dicts, change_count).
136
+ """
137
+ changes = []
138
+ count = 0
139
+
140
+ # Typography changes
141
+ typo_mods = diff.get("typography", {}).get("modify", [])
142
+ for item in typo_mods:
143
+ name = item.get("name", "")
144
+ diffs = item.get("diffs", {})
145
+ instructions = []
146
+
147
+ for field, vals in diffs.items():
148
+ bench = vals.get("benchmark", "")
149
+ current = vals.get("current", "")
150
+ if field == "size":
151
+ instructions.append("Change `text-{}` → `text-{}`".format(current, bench))
152
+ elif field == "weight":
153
+ weight_map = {
154
+ "100": "thin", "200": "extralight", "300": "light",
155
+ "400": "normal", "500": "medium", "510": "[510]",
156
+ "590": "[590]", "600": "semibold", "700": "bold",
157
+ "800": "extrabold", "900": "black",
158
+ }
159
+ b_tw = weight_map.get(str(bench), "[{}]".format(bench))
160
+ s_tw = weight_map.get(str(current), "[{}]".format(current))
161
+ instructions.append("Change `font-{}` → `font-{}`".format(s_tw, b_tw))
162
+ elif field == "family":
163
+ instructions.append("Update font-family from `{}` → `{}`".format(current, bench))
164
+ elif field == "lineHeight":
165
+ instructions.append("Update line-height from `{}` → `{}`".format(current, bench))
166
+ elif field == "letterSpacing":
167
+ instructions.append("Update letter-spacing from `{}` → `{}`".format(current, bench))
168
+ else:
169
+ instructions.append("Update {} from `{}` → `{}`".format(field, current, bench))
170
+
171
+ if instructions:
172
+ changes.append({
173
+ "component": "Typography / {}".format(name),
174
+ "instructions": instructions,
175
+ })
176
+ count += len(instructions)
177
+
178
+ # Spacing changes
179
+ spacing_mods = diff.get("spacing", {}).get("modify", [])
180
+ for item in spacing_mods:
181
+ name = item.get("name", "")
182
+ bench = _summarize(item.get("benchmark"))
183
+ current = _summarize(item.get("current"))
184
+ changes.append({
185
+ "component": "Spacing / {}".format(name),
186
+ "instructions": [
187
+ "Change spacing from `{}` → `{}` (Tailwind units)".format(current, bench),
188
+ ],
189
+ })
190
+ count += 1
191
+
192
+ # Component-level changes
193
+ comp_mods = diff.get("components", {}).get("modify", [])
194
+ for item in comp_mods:
195
+ name = item.get("name", "")
196
+ diffs = item.get("diffs", {})
197
+ instructions = []
198
+
199
+ if isinstance(diffs, dict):
200
+ for field, vals in diffs.items():
201
+ if isinstance(vals, dict) and "benchmark" in vals:
202
+ bench = _summarize(vals.get("benchmark"))
203
+ current = _summarize(vals.get("current"))
204
+ instructions.append("Update `{}`: `{}` → `{}`".format(field, current, bench))
205
+ else:
206
+ instructions.append("Update `{}`: {}".format(field, _summarize(vals)))
207
+
208
+ if instructions:
209
+ changes.append({
210
+ "component": "Component / {}".format(name),
211
+ "instructions": instructions,
212
+ })
213
+ count += len(instructions)
214
+
215
+ # Component additions
216
+ comp_adds = diff.get("components", {}).get("add", [])
217
+ for item in comp_adds:
218
+ name = item.get("name", "")
219
+ bench = item.get("benchmark", "")
220
+ changes.append({
221
+ "component": "Component / {} (NEW)".format(name),
222
+ "instructions": [
223
+ "Create new component styling based on benchmark: {}".format(
224
+ _summarize(bench)
225
+ ),
226
+ ],
227
+ })
228
+ count += 1
229
+
230
+ return changes, count
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # 4. Phase 3: Layout Changes
235
+ # ---------------------------------------------------------------------------
236
+
237
+ def generate_phase3_layout(diff: dict) -> tuple[list[dict], int]:
238
+ """
239
+ Generate layout-level structural change instructions.
240
+ Returns (list of layout change dicts, change_count).
241
+ """
242
+ changes = []
243
+ count = 0
244
+
245
+ layout_mods = diff.get("layout", {}).get("modify", [])
246
+ for item in layout_mods:
247
+ name = item.get("name", "")
248
+ bench = _summarize(item.get("benchmark"))
249
+ current = _summarize(item.get("current"))
250
+ changes.append({
251
+ "property": name,
252
+ "from": current,
253
+ "to": bench,
254
+ "risk": "HIGH" if name in ("maxWidth", "gridColumns") else "MEDIUM",
255
+ "note": "Structural change — requires visual regression testing",
256
+ })
257
+ count += 1
258
+
259
+ layout_adds = diff.get("layout", {}).get("add", [])
260
+ for item in layout_adds:
261
+ name = item.get("name", "")
262
+ bench = _summarize(item.get("benchmark"))
263
+ changes.append({
264
+ "property": name,
265
+ "from": "(none)",
266
+ "to": bench,
267
+ "risk": "HIGH",
268
+ "note": "New layout constraint — verify across breakpoints",
269
+ })
270
+ count += 1
271
+
272
+ return changes, count
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # 5. Conflict resolution
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def generate_conflict_section(diff: dict) -> list[dict]:
280
+ """Generate conflict resolution proposals."""
281
+ conflicts = diff.get("conflicts", [])
282
+ resolutions = []
283
+
284
+ for c in conflicts:
285
+ rule_id = c.get("ruleId", "")
286
+ rule = c.get("rule", "")
287
+ bench_val = c.get("benchmarkValue", "")
288
+ component = c.get("component", "")
289
+ resolution = c.get("resolution", "")
290
+
291
+ resolutions.append({
292
+ "ruleId": rule_id,
293
+ "rule": rule,
294
+ "benchmarkValue": bench_val,
295
+ "component": component,
296
+ "proposedChange": resolution,
297
+ "file": "FRONTEND_CONSISTENCY.md",
298
+ })
299
+
300
+ return resolutions
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # 6. Effort estimation
305
+ # ---------------------------------------------------------------------------
306
+
307
+ def estimate_effort(diff: dict) -> dict:
308
+ """Calculate effort estimation from the diff."""
309
+ total_points = 0
310
+ breakdown = {}
311
+
312
+ for category, weight in COMPLEXITY_WEIGHTS.items():
313
+ cat_data = diff.get(category, {})
314
+ if isinstance(cat_data, dict):
315
+ mod_count = len(cat_data.get("modify", []))
316
+ add_count = len(cat_data.get("add", []))
317
+ change_count = mod_count + add_count
318
+ points = change_count * weight
319
+ if change_count > 0:
320
+ breakdown[category] = {
321
+ "changes": change_count,
322
+ "weight": weight,
323
+ "points": points,
324
+ }
325
+ total_points += points
326
+
327
+ conflict_count = len(diff.get("conflicts", []))
328
+ if conflict_count > 0:
329
+ conflict_points = conflict_count * 2 # Conflicts need discussion/decision
330
+ breakdown["conflicts"] = {
331
+ "changes": conflict_count,
332
+ "weight": 2,
333
+ "points": conflict_points,
334
+ }
335
+ total_points += conflict_points
336
+
337
+ return {
338
+ "totalPoints": total_points,
339
+ "label": effort_label(total_points),
340
+ "breakdown": breakdown,
341
+ }
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # 7. Markdown generation
346
+ # ---------------------------------------------------------------------------
347
+
348
+ def render_markdown(diff: dict) -> str:
349
+ """Render the full migration plan as markdown."""
350
+ summary = diff.get("summary", {})
351
+ today = date.today().isoformat()
352
+
353
+ parts = []
354
+
355
+ # Header
356
+ parts.append("# UI Mirror — Migration Plan")
357
+ parts.append("")
358
+ parts.append("> Generated: {}".format(today))
359
+ parts.append("> Tokens compared: {} | Match: {} | Modify: {} | Add: {} | Conflicts: {}".format(
360
+ summary.get("total", 0),
361
+ summary.get("match", 0),
362
+ summary.get("modify", 0),
363
+ summary.get("add", 0),
364
+ summary.get("conflicts", 0),
365
+ ))
366
+ parts.append("")
367
+ parts.append("---")
368
+ parts.append("")
369
+
370
+ # Effort estimation
371
+ effort = estimate_effort(diff)
372
+ parts.append("## Effort Estimation")
373
+ parts.append("")
374
+ parts.append("**Total: {} ({} points)**".format(effort["label"], effort["totalPoints"]))
375
+ parts.append("")
376
+ if effort["breakdown"]:
377
+ parts.append("| Category | Changes | Weight | Points |")
378
+ parts.append("|----------|---------|--------|--------|")
379
+ for cat, info in effort["breakdown"].items():
380
+ parts.append("| {} | {} | x{} | {} |".format(
381
+ cat, info["changes"], info["weight"], info["points"]
382
+ ))
383
+ parts.append("")
384
+ parts.append("---")
385
+ parts.append("")
386
+
387
+ # Phase 1: CSS Token Overrides
388
+ css_block, css_count = generate_phase1_css(diff)
389
+ parts.append("## Phase 1 — CSS Token Overrides")
390
+ parts.append("")
391
+ parts.append("> **Risk: LOW** | **Impact: HIGH** | Changes: {}".format(css_count))
392
+ parts.append(">")
393
+ parts.append("> Update `:root` variables in `src/app/globals.css`. No component code changes needed.")
394
+ parts.append("")
395
+
396
+ if css_block:
397
+ parts.append("```css")
398
+ parts.append(css_block)
399
+ parts.append("```")
400
+ else:
401
+ parts.append("*No CSS token overrides needed — all tokens match.*")
402
+ parts.append("")
403
+ parts.append("---")
404
+ parts.append("")
405
+
406
+ # Phase 2: Component Modifications
407
+ component_changes, comp_count = generate_phase2_components(diff)
408
+ parts.append("## Phase 2 — Component Modifications")
409
+ parts.append("")
410
+ parts.append("> **Risk: MEDIUM** | Changes: {}".format(comp_count))
411
+ parts.append(">")
412
+ parts.append("> Per-component Tailwind class and style updates.")
413
+ parts.append("")
414
+
415
+ if component_changes:
416
+ for change in component_changes:
417
+ parts.append("### {}".format(change["component"]))
418
+ parts.append("")
419
+ for instruction in change.get("instructions", []):
420
+ parts.append("- {}".format(instruction))
421
+ parts.append("")
422
+ else:
423
+ parts.append("*No component modifications needed.*")
424
+ parts.append("")
425
+
426
+ parts.append("---")
427
+ parts.append("")
428
+
429
+ # Phase 3: Layout Changes
430
+ layout_changes, layout_count = generate_phase3_layout(diff)
431
+ parts.append("## Phase 3 — Layout Changes")
432
+ parts.append("")
433
+ parts.append("> **Risk: HIGH** | Changes: {}".format(layout_count))
434
+ parts.append(">")
435
+ parts.append("> Structural grid/spacing/max-width changes. Requires careful visual regression testing.")
436
+ parts.append("")
437
+
438
+ if layout_changes:
439
+ parts.append("| Property | From | To | Risk | Note |")
440
+ parts.append("|----------|------|----|------|------|")
441
+ for lc in layout_changes:
442
+ parts.append("| {} | {} | {} | {} | {} |".format(
443
+ lc["property"], lc["from"], lc["to"], lc["risk"], lc["note"]
444
+ ))
445
+ parts.append("")
446
+ parts.append("**Testing checklist:**")
447
+ parts.append("- [ ] Desktop (1440px+)")
448
+ parts.append("- [ ] Laptop (1024px)")
449
+ parts.append("- [ ] Tablet (768px)")
450
+ parts.append("- [ ] Mobile (375px)")
451
+ parts.append("")
452
+ else:
453
+ parts.append("*No layout changes needed.*")
454
+ parts.append("")
455
+
456
+ parts.append("---")
457
+ parts.append("")
458
+
459
+ # Conflict Resolution
460
+ resolutions = generate_conflict_section(diff)
461
+ parts.append("## Conflict Resolution")
462
+ parts.append("")
463
+
464
+ if resolutions:
465
+ parts.append("> {} conflict(s) detected between benchmark design and FRONTEND_CONSISTENCY.md rules.".format(
466
+ len(resolutions)
467
+ ))
468
+ parts.append("")
469
+
470
+ for res in resolutions:
471
+ parts.append("### {} — {}".format(res["ruleId"], res["rule"]))
472
+ parts.append("")
473
+ parts.append("- **Benchmark value**: `{}`".format(res["benchmarkValue"]))
474
+ if res.get("component"):
475
+ parts.append("- **Component**: `{}`".format(res["component"]))
476
+ parts.append("- **Proposed change**: {}".format(res["proposedChange"]))
477
+ parts.append("- **File**: `{}`".format(res["file"]))
478
+ parts.append("")
479
+ else:
480
+ parts.append("*No conflicts detected.*")
481
+ parts.append("")
482
+
483
+ parts.append("---")
484
+ parts.append("")
485
+
486
+ # Summary of matched tokens (brief)
487
+ parts.append("## Matched Tokens (no action needed)")
488
+ parts.append("")
489
+ match_count = summary.get("match", 0)
490
+ if match_count > 0:
491
+ parts.append("{} tokens already match the benchmark. No changes required for these.".format(match_count))
492
+ parts.append("")
493
+
494
+ # List matched categories briefly
495
+ for cat in ["colors", "typography", "spacing", "radius", "shadows", "components", "layout"]:
496
+ cat_data = diff.get(cat, {})
497
+ matches = cat_data.get("match", []) if isinstance(cat_data, dict) else []
498
+ if matches:
499
+ names = [m.get("name", "?") for m in matches]
500
+ parts.append("- **{}**: {}".format(cat, ", ".join(names)))
501
+ parts.append("")
502
+ else:
503
+ parts.append("*No tokens matched. Full migration required.*")
504
+ parts.append("")
505
+
506
+ return "\n".join(parts)
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # 8. Helpers
511
+ # ---------------------------------------------------------------------------
512
+
513
+ def _summarize(v) -> str:
514
+ """Produce a compact string summary of a token value."""
515
+ if v is None:
516
+ return "(none)"
517
+ if isinstance(v, str):
518
+ return v
519
+ if isinstance(v, (int, float)):
520
+ return str(v)
521
+ if isinstance(v, dict):
522
+ if "oklch" in v:
523
+ return str(v["oklch"])
524
+ if "px" in v and v["px"] is not None:
525
+ return "{}px".format(v["px"])
526
+ if "tailwind" in v:
527
+ return str(v["tailwind"])
528
+ # Compact JSON
529
+ return json.dumps(v, ensure_ascii=False)
530
+ if isinstance(v, list):
531
+ return json.dumps(v, ensure_ascii=False)
532
+ return str(v)
533
+
534
+
535
+ def _extract_px(s: str) -> str:
536
+ """Extract numeric px value from a string, or return the string as-is."""
537
+ # Handle Tailwind size names that may arrive from _summarize
538
+ tw_to_px = {
539
+ "none": "0", "sm": "2", "DEFAULT": "4", "md": "6",
540
+ "lg": "8", "xl": "12", "2xl": "16", "3xl": "24", "full": "9999",
541
+ }
542
+ s_str = str(s).strip()
543
+ if s_str in tw_to_px:
544
+ return tw_to_px[s_str]
545
+ m = re.search(r"([\d.]+)", s_str)
546
+ return m.group(1) if m else s_str
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # 9. Entry point
551
+ # ---------------------------------------------------------------------------
552
+
553
+ def main():
554
+ if len(sys.argv) < 2:
555
+ print("Usage: {} <token_diff.json>".format(sys.argv[0]), file=sys.stderr)
556
+ print(" Transforms a token diff into a prioritized markdown migration plan.", file=sys.stderr)
557
+ sys.exit(1)
558
+
559
+ diff_path = sys.argv[1]
560
+
561
+ try:
562
+ with open(diff_path, "r", encoding="utf-8") as f:
563
+ diff = json.load(f)
564
+ except FileNotFoundError:
565
+ print("Error: File not found: {}".format(diff_path), file=sys.stderr)
566
+ sys.exit(1)
567
+ except json.JSONDecodeError as e:
568
+ print("Error: Invalid JSON in {}: {}".format(diff_path, e), file=sys.stderr)
569
+ sys.exit(1)
570
+
571
+ if not isinstance(diff, dict):
572
+ print("Error: Expected top-level JSON object, got {}".format(type(diff).__name__), file=sys.stderr)
573
+ sys.exit(1)
574
+
575
+ markdown = render_markdown(diff)
576
+ print(markdown)
577
+
578
+
579
+ if __name__ == "__main__":
580
+ main()