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.
- package/README.md +95 -0
- package/bin/cli.mjs +121 -0
- package/package.json +34 -0
- package/skill/SKILL.md +751 -0
- package/skill/references/analysis-dimensions.md +382 -0
- package/skill/references/component-catalog.md +758 -0
- package/skill/references/css-token-mapping.md +359 -0
- package/skill/references/output-template.md +249 -0
- package/skill/scripts/compare_tokens.py +741 -0
- package/skill/scripts/download_screenshot.py +125 -0
- package/skill/scripts/extract_design_tokens.py +617 -0
- package/skill/scripts/generate_migration.py +580 -0
- package/skill/scripts/generate_radar_chart.py +267 -0
|
@@ -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()
|