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,617 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
extract_design_tokens.py — Parse raw computed styles JSON into normalized design tokens.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 extract_design_tokens.py <input.json>
|
|
7
|
+
|
|
8
|
+
Input: JSON file with browser-extracted computed styles (body, headings, buttons, cards, etc.)
|
|
9
|
+
Output: Normalized design tokens JSON to stdout.
|
|
10
|
+
|
|
11
|
+
Dependencies: Python 3 standard library only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# 1. Color conversion: sRGB → linear RGB → XYZ → Oklab → OKLCH
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def _srgb_transfer(c: float) -> float:
|
|
26
|
+
"""Inverse sRGB companding: sRGB [0,1] → linear [0,1]."""
|
|
27
|
+
if c <= 0.04045:
|
|
28
|
+
return c / 12.92
|
|
29
|
+
return ((c + 0.055) / 1.055) ** 2.4
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def srgb_to_linear(r: float, g: float, b: float):
|
|
33
|
+
"""Convert sRGB [0,255] to linear RGB [0,1]."""
|
|
34
|
+
return _srgb_transfer(r / 255.0), _srgb_transfer(g / 255.0), _srgb_transfer(b / 255.0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def linear_rgb_to_xyz(lr: float, lg: float, lb: float):
|
|
38
|
+
"""Linear sRGB → CIE XYZ (D65) using the standard 3×3 matrix."""
|
|
39
|
+
x = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb
|
|
40
|
+
y = 0.2126729 * lr + 0.7151522 * lg + 0.0721750 * lb
|
|
41
|
+
z = 0.0193339 * lr + 0.1191920 * lg + 0.9503041 * lb
|
|
42
|
+
return x, y, z
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def xyz_to_oklab(x: float, y: float, z: float):
|
|
46
|
+
"""CIE XYZ → Oklab via the Björn Ottosson method."""
|
|
47
|
+
# XYZ → LMS (using Ottosson's M1 matrix)
|
|
48
|
+
l_ = 0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z
|
|
49
|
+
m_ = 0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z
|
|
50
|
+
s_ = 0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z
|
|
51
|
+
|
|
52
|
+
# Cube root (handle negatives gracefully)
|
|
53
|
+
def cbrt(v):
|
|
54
|
+
if v >= 0:
|
|
55
|
+
return v ** (1.0 / 3.0)
|
|
56
|
+
return -((-v) ** (1.0 / 3.0))
|
|
57
|
+
|
|
58
|
+
l_c = cbrt(l_)
|
|
59
|
+
m_c = cbrt(m_)
|
|
60
|
+
s_c = cbrt(s_)
|
|
61
|
+
|
|
62
|
+
# LMS′ → Oklab (M2 matrix)
|
|
63
|
+
L = 0.2104542553 * l_c + 0.7936177850 * m_c - 0.0040720468 * s_c
|
|
64
|
+
a = 1.9779984951 * l_c - 2.4285922050 * m_c + 0.4505937099 * s_c
|
|
65
|
+
b = 0.0259040371 * l_c + 0.7827717662 * m_c - 0.8086757660 * s_c
|
|
66
|
+
return L, a, b
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def oklab_to_oklch(L: float, a: float, b: float):
|
|
70
|
+
"""Oklab → OKLCH (Lightness, Chroma, Hue in degrees)."""
|
|
71
|
+
C = math.sqrt(a * a + b * b)
|
|
72
|
+
H = math.degrees(math.atan2(b, a)) % 360
|
|
73
|
+
return L, C, H
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rgb_to_oklch(r: int, g: int, b: int):
|
|
77
|
+
"""Full pipeline: sRGB [0,255] → OKLCH."""
|
|
78
|
+
lr, lg, lb = srgb_to_linear(r, g, b)
|
|
79
|
+
x, y, z = linear_rgb_to_xyz(lr, lg, lb)
|
|
80
|
+
L, a, ob = xyz_to_oklab(x, y, z)
|
|
81
|
+
return oklab_to_oklch(L, a, ob)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def rgb_to_hex(r: int, g: int, b: int) -> str:
|
|
85
|
+
return "#{:02X}{:02X}{:02X}".format(r, g, b)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_oklch(L: float, C: float, H: float) -> str:
|
|
89
|
+
return "oklch({:.3f} {:.3f} {:.1f})".format(L, C, H)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# 2. Parse CSS color strings
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
_RGB_RE = re.compile(
|
|
97
|
+
r"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+)?\s*\)"
|
|
98
|
+
)
|
|
99
|
+
_HEX_RE = re.compile(r"#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_rgb(s: str):
|
|
103
|
+
"""Return (r, g, b) tuple from 'rgb(...)' or 'rgba(...)' string, or None."""
|
|
104
|
+
if not s:
|
|
105
|
+
return None
|
|
106
|
+
m = _RGB_RE.search(s)
|
|
107
|
+
if m:
|
|
108
|
+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
109
|
+
m = _HEX_RE.search(s)
|
|
110
|
+
if m:
|
|
111
|
+
h = m.group(1)
|
|
112
|
+
if len(h) == 3:
|
|
113
|
+
h = h[0] * 2 + h[1] * 2 + h[2] * 2
|
|
114
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def color_entry(rgb_str: str, tailwind_name: str = ""):
|
|
119
|
+
"""Build a normalized color entry dict from an rgb string."""
|
|
120
|
+
parsed = parse_rgb(rgb_str)
|
|
121
|
+
if parsed is None:
|
|
122
|
+
return {"raw": rgb_str, "tailwind": tailwind_name}
|
|
123
|
+
r, g, b = parsed
|
|
124
|
+
L, C, H = rgb_to_oklch(r, g, b)
|
|
125
|
+
return {
|
|
126
|
+
"oklch": format_oklch(L, C, H),
|
|
127
|
+
"rgb": "rgb({},{},{})".format(r, g, b),
|
|
128
|
+
"hex": rgb_to_hex(r, g, b),
|
|
129
|
+
"tailwind": tailwind_name,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# 3. Tailwind mapping tables
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
FONT_SIZE_MAP = {
|
|
138
|
+
12: "xs", 13: "xs", 14: "sm", 15: "sm", 16: "base",
|
|
139
|
+
18: "lg", 20: "xl", 24: "2xl", 28: "2xl", 30: "3xl",
|
|
140
|
+
36: "4xl", 48: "5xl", 60: "6xl", 72: "7xl", 96: "8xl", 128: "9xl",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
RADIUS_MAP = {
|
|
144
|
+
0: "none", 1: "sm", 2: "sm", 3: "DEFAULT", 4: "DEFAULT",
|
|
145
|
+
5: "md", 6: "md", 7: "lg", 8: "lg", 10: "xl", 12: "xl",
|
|
146
|
+
14: "2xl", 16: "2xl", 20: "3xl", 24: "3xl", 9999: "full",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
MAX_WIDTH_MAP = {
|
|
150
|
+
320: "xs", 384: "sm", 448: "md", 512: "lg", 576: "xl",
|
|
151
|
+
672: "2xl", 768: "3xl", 896: "4xl", 1024: "5xl", 1152: "6xl",
|
|
152
|
+
1280: "7xl",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
SPACING_MULTIPLES = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10,
|
|
156
|
+
11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56,
|
|
157
|
+
60, 64, 72, 80, 96]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_px(s: str) -> float | None:
|
|
161
|
+
"""Extract numeric px value from a CSS string like '16px'."""
|
|
162
|
+
if not s:
|
|
163
|
+
return None
|
|
164
|
+
m = re.search(r"([\d.]+)\s*px", str(s))
|
|
165
|
+
return float(m.group(1)) if m else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def px_to_tailwind_spacing(px_val: float) -> str:
|
|
169
|
+
"""Map a px value to the nearest Tailwind spacing unit (4px = 1)."""
|
|
170
|
+
tw_unit = px_val / 4.0
|
|
171
|
+
best = min(SPACING_MULTIPLES, key=lambda v: abs(v - tw_unit))
|
|
172
|
+
# Tailwind uses integer-like labels: 0.5, 1, 1.5 etc.
|
|
173
|
+
if best == int(best):
|
|
174
|
+
return str(int(best))
|
|
175
|
+
return str(best)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def px_to_tailwind_font_size(px_val: float) -> str:
|
|
179
|
+
rounded = round(px_val)
|
|
180
|
+
if rounded in FONT_SIZE_MAP:
|
|
181
|
+
return FONT_SIZE_MAP[rounded]
|
|
182
|
+
# Find nearest
|
|
183
|
+
closest = min(FONT_SIZE_MAP.keys(), key=lambda k: abs(k - rounded))
|
|
184
|
+
return FONT_SIZE_MAP[closest]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def px_to_tailwind_radius(px_val: float) -> str:
|
|
188
|
+
rounded = round(px_val)
|
|
189
|
+
if rounded >= 9999:
|
|
190
|
+
return "full"
|
|
191
|
+
if rounded in RADIUS_MAP:
|
|
192
|
+
return RADIUS_MAP[rounded]
|
|
193
|
+
closest = min(RADIUS_MAP.keys(), key=lambda k: abs(k - rounded))
|
|
194
|
+
return RADIUS_MAP[closest]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def px_to_tailwind_max_width(px_val: float) -> str:
|
|
198
|
+
rounded = round(px_val)
|
|
199
|
+
if rounded in MAX_WIDTH_MAP:
|
|
200
|
+
return MAX_WIDTH_MAP[rounded]
|
|
201
|
+
closest = min(MAX_WIDTH_MAP.keys(), key=lambda k: abs(k - rounded))
|
|
202
|
+
return MAX_WIDTH_MAP[closest]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# 4. Font family identification
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
GENERIC_FAMILIES = {
|
|
210
|
+
"serif", "sans-serif", "monospace", "cursive", "fantasy",
|
|
211
|
+
"system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded",
|
|
212
|
+
"math", "emoji", "fangsong",
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# Well-known Google Fonts (non-exhaustive; covers common ones)
|
|
216
|
+
GOOGLE_FONTS = {
|
|
217
|
+
"Inter", "Roboto", "Open Sans", "Lato", "Montserrat", "Poppins",
|
|
218
|
+
"Nunito", "Raleway", "Ubuntu", "Playfair Display", "Merriweather",
|
|
219
|
+
"Source Sans Pro", "Noto Sans", "Oswald", "Quicksand", "Pretendard",
|
|
220
|
+
"Noto Sans KR", "IBM Plex Sans", "DM Sans", "Manrope", "Outfit",
|
|
221
|
+
"Plus Jakarta Sans", "Space Grotesk", "Figtree", "Geist",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def identify_font(font_family_str: str) -> dict:
|
|
226
|
+
"""Extract the first non-generic font name and identify if Google Font."""
|
|
227
|
+
if not font_family_str:
|
|
228
|
+
return {"name": "system-ui", "isGoogleFont": False}
|
|
229
|
+
parts = [p.strip().strip("'\"") for p in font_family_str.split(",")]
|
|
230
|
+
for part in parts:
|
|
231
|
+
lower = part.lower()
|
|
232
|
+
if lower not in {g.lower() for g in GENERIC_FAMILIES}:
|
|
233
|
+
is_google = any(part.lower() == gf.lower() for gf in GOOGLE_FONTS)
|
|
234
|
+
return {"name": part, "isGoogleFont": is_google}
|
|
235
|
+
return {"name": parts[0] if parts else "system-ui", "isGoogleFont": False}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# 5. Padding parsing (handles "8px 16px" shorthand)
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def parse_padding(s: str) -> dict:
|
|
243
|
+
"""Parse CSS padding shorthand into a dict with px and tailwind values."""
|
|
244
|
+
if not s:
|
|
245
|
+
return {}
|
|
246
|
+
px_values = re.findall(r"([\d.]+)\s*px", str(s))
|
|
247
|
+
if not px_values:
|
|
248
|
+
return {"raw": s}
|
|
249
|
+
nums = [float(v) for v in px_values]
|
|
250
|
+
# Use the first value as representative (vertical padding typically)
|
|
251
|
+
representative = nums[0]
|
|
252
|
+
return {
|
|
253
|
+
"raw": s,
|
|
254
|
+
"px": representative,
|
|
255
|
+
"tailwind": px_to_tailwind_spacing(representative),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# 6. Color clustering by usage context
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def classify_color_role(context: str, rgb_tuple) -> str:
|
|
264
|
+
"""Heuristic: assign a semantic role based on where the color was found."""
|
|
265
|
+
r, g, b = rgb_tuple
|
|
266
|
+
lightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
|
267
|
+
|
|
268
|
+
ctx = context.lower()
|
|
269
|
+
|
|
270
|
+
if "button" in ctx and "bg" in ctx:
|
|
271
|
+
return "primary"
|
|
272
|
+
if "button" in ctx and "color" in ctx:
|
|
273
|
+
return "primary-foreground"
|
|
274
|
+
if "body" in ctx and "bg" in ctx:
|
|
275
|
+
return "background"
|
|
276
|
+
if "body" in ctx and "color" in ctx:
|
|
277
|
+
return "foreground"
|
|
278
|
+
if "card" in ctx and "bg" in ctx:
|
|
279
|
+
return "card"
|
|
280
|
+
if "card" in ctx and "border" in ctx:
|
|
281
|
+
return "border"
|
|
282
|
+
if "nav" in ctx and "bg" in ctx:
|
|
283
|
+
return "card" # nav bg is typically card-like
|
|
284
|
+
if "input" in ctx and "bg" in ctx:
|
|
285
|
+
return "input"
|
|
286
|
+
if "input" in ctx and "border" in ctx:
|
|
287
|
+
return "border"
|
|
288
|
+
if "heading" in ctx:
|
|
289
|
+
return "foreground"
|
|
290
|
+
|
|
291
|
+
# Fallback by lightness
|
|
292
|
+
if lightness > 0.9:
|
|
293
|
+
return "background"
|
|
294
|
+
if lightness < 0.2:
|
|
295
|
+
return "foreground"
|
|
296
|
+
if lightness < 0.5:
|
|
297
|
+
return "secondary"
|
|
298
|
+
return "muted"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
# 7. Main extraction logic
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
def extract_tokens(data: dict) -> dict:
|
|
306
|
+
"""Transform raw computed styles into normalized design tokens."""
|
|
307
|
+
|
|
308
|
+
tokens = {
|
|
309
|
+
"colors": {},
|
|
310
|
+
"typography": {},
|
|
311
|
+
"spacing": {},
|
|
312
|
+
"radius": {},
|
|
313
|
+
"shadows": {},
|
|
314
|
+
"components": {},
|
|
315
|
+
"layout": {},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Track all extracted colors for clustering
|
|
319
|
+
all_colors = [] # [(role, rgb_str, rgb_tuple)]
|
|
320
|
+
|
|
321
|
+
# ---- Body ----
|
|
322
|
+
body = data.get("body", {})
|
|
323
|
+
if body:
|
|
324
|
+
body_bg = body.get("bg", "")
|
|
325
|
+
body_color = body.get("color", "")
|
|
326
|
+
body_font = body.get("fontFamily", "")
|
|
327
|
+
body_size = body.get("fontSize", "")
|
|
328
|
+
body_lh = body.get("lineHeight", "")
|
|
329
|
+
|
|
330
|
+
if body_bg:
|
|
331
|
+
parsed = parse_rgb(body_bg)
|
|
332
|
+
if parsed:
|
|
333
|
+
all_colors.append(("body.bg", body_bg, parsed))
|
|
334
|
+
tokens["colors"]["background"] = color_entry(body_bg, "background")
|
|
335
|
+
|
|
336
|
+
if body_color:
|
|
337
|
+
parsed = parse_rgb(body_color)
|
|
338
|
+
if parsed:
|
|
339
|
+
all_colors.append(("body.color", body_color, parsed))
|
|
340
|
+
tokens["colors"]["foreground"] = color_entry(body_color, "foreground")
|
|
341
|
+
|
|
342
|
+
font_info = identify_font(body_font)
|
|
343
|
+
size_px = parse_px(body_size)
|
|
344
|
+
tokens["typography"]["body"] = {
|
|
345
|
+
"family": font_info["name"],
|
|
346
|
+
"isGoogleFont": font_info["isGoogleFont"],
|
|
347
|
+
"size": px_to_tailwind_font_size(size_px) if size_px else "base",
|
|
348
|
+
"sizePx": size_px,
|
|
349
|
+
"weight": "400",
|
|
350
|
+
"lineHeight": body_lh or "1.5",
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
# ---- Headings ----
|
|
354
|
+
headings = data.get("headings", [])
|
|
355
|
+
for i, h in enumerate(headings):
|
|
356
|
+
tag = h.get("tag", "H{}".format(i + 1)).upper()
|
|
357
|
+
level = tag.replace("H", "") if tag.startswith("H") else str(i + 1)
|
|
358
|
+
key = "heading{}".format(level)
|
|
359
|
+
|
|
360
|
+
size_px = parse_px(h.get("fontSize", ""))
|
|
361
|
+
font_info = identify_font(h.get("fontFamily", ""))
|
|
362
|
+
|
|
363
|
+
tokens["typography"][key] = {
|
|
364
|
+
"family": font_info["name"],
|
|
365
|
+
"size": px_to_tailwind_font_size(size_px) if size_px else "base",
|
|
366
|
+
"sizePx": size_px,
|
|
367
|
+
"weight": h.get("fontWeight", "700"),
|
|
368
|
+
"lineHeight": h.get("lineHeight", "1.2"),
|
|
369
|
+
"letterSpacing": h.get("letterSpacing", "normal"),
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
heading_color = h.get("color", "")
|
|
373
|
+
if heading_color:
|
|
374
|
+
parsed = parse_rgb(heading_color)
|
|
375
|
+
if parsed:
|
|
376
|
+
all_colors.append(("heading.color", heading_color, parsed))
|
|
377
|
+
|
|
378
|
+
# Heading spacing
|
|
379
|
+
mb_px = parse_px(h.get("marginBottom", ""))
|
|
380
|
+
mt_px = parse_px(h.get("marginTop", ""))
|
|
381
|
+
if mb_px is not None:
|
|
382
|
+
tokens["spacing"]["heading{}_mb".format(level)] = {
|
|
383
|
+
"px": mb_px,
|
|
384
|
+
"tailwind": px_to_tailwind_spacing(mb_px),
|
|
385
|
+
}
|
|
386
|
+
if mt_px is not None:
|
|
387
|
+
tokens["spacing"]["heading{}_mt".format(level)] = {
|
|
388
|
+
"px": mt_px,
|
|
389
|
+
"tailwind": px_to_tailwind_spacing(mt_px),
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
# ---- Buttons ----
|
|
393
|
+
buttons = data.get("buttons", [])
|
|
394
|
+
button_variants = []
|
|
395
|
+
for i, btn in enumerate(buttons):
|
|
396
|
+
variant = {}
|
|
397
|
+
btn_bg = btn.get("bg", "")
|
|
398
|
+
btn_color = btn.get("color", "")
|
|
399
|
+
|
|
400
|
+
if btn_bg:
|
|
401
|
+
parsed = parse_rgb(btn_bg)
|
|
402
|
+
if parsed:
|
|
403
|
+
all_colors.append(("button.bg", btn_bg, parsed))
|
|
404
|
+
variant["bg"] = color_entry(btn_bg)
|
|
405
|
+
# First button bg is likely "primary"
|
|
406
|
+
if i == 0 and "primary" not in tokens["colors"]:
|
|
407
|
+
tokens["colors"]["primary"] = color_entry(btn_bg, "primary")
|
|
408
|
+
|
|
409
|
+
if btn_color:
|
|
410
|
+
parsed = parse_rgb(btn_color)
|
|
411
|
+
if parsed:
|
|
412
|
+
all_colors.append(("button.color", btn_color, parsed))
|
|
413
|
+
variant["color"] = color_entry(btn_color)
|
|
414
|
+
if i == 0 and "primary-foreground" not in tokens["colors"]:
|
|
415
|
+
tokens["colors"]["primary-foreground"] = color_entry(btn_color, "primary-foreground")
|
|
416
|
+
|
|
417
|
+
radius_px = parse_px(btn.get("borderRadius", ""))
|
|
418
|
+
if radius_px is not None:
|
|
419
|
+
variant["radius"] = {"px": radius_px, "tailwind": px_to_tailwind_radius(radius_px)}
|
|
420
|
+
tokens["radius"]["button"] = variant["radius"]
|
|
421
|
+
|
|
422
|
+
variant["padding"] = parse_padding(btn.get("padding", ""))
|
|
423
|
+
|
|
424
|
+
size_px = parse_px(btn.get("fontSize", ""))
|
|
425
|
+
if size_px:
|
|
426
|
+
variant["fontSize"] = px_to_tailwind_font_size(size_px)
|
|
427
|
+
|
|
428
|
+
variant["fontWeight"] = btn.get("fontWeight", "500")
|
|
429
|
+
variant["border"] = btn.get("border", "none")
|
|
430
|
+
variant["boxShadow"] = btn.get("boxShadow", "none")
|
|
431
|
+
variant["text"] = btn.get("text", "")
|
|
432
|
+
|
|
433
|
+
button_variants.append(variant)
|
|
434
|
+
|
|
435
|
+
if button_variants:
|
|
436
|
+
tokens["components"]["button"] = {"variants": button_variants}
|
|
437
|
+
|
|
438
|
+
# ---- Cards ----
|
|
439
|
+
cards = data.get("cards", [])
|
|
440
|
+
if cards:
|
|
441
|
+
card = cards[0] # Use first card as canonical
|
|
442
|
+
card_bg = card.get("bg", "")
|
|
443
|
+
if card_bg:
|
|
444
|
+
parsed = parse_rgb(card_bg)
|
|
445
|
+
if parsed:
|
|
446
|
+
all_colors.append(("card.bg", card_bg, parsed))
|
|
447
|
+
tokens["colors"]["card"] = color_entry(card_bg, "card")
|
|
448
|
+
|
|
449
|
+
radius_px = parse_px(card.get("borderRadius", ""))
|
|
450
|
+
if radius_px is not None:
|
|
451
|
+
tokens["radius"]["card"] = {"px": radius_px, "tailwind": px_to_tailwind_radius(radius_px)}
|
|
452
|
+
|
|
453
|
+
padding = parse_padding(card.get("padding", ""))
|
|
454
|
+
if padding:
|
|
455
|
+
tokens["spacing"]["card_padding"] = padding if "px" in padding else padding
|
|
456
|
+
|
|
457
|
+
shadow = card.get("boxShadow", "")
|
|
458
|
+
if shadow:
|
|
459
|
+
tokens["shadows"]["card"] = shadow
|
|
460
|
+
|
|
461
|
+
border = card.get("border", "")
|
|
462
|
+
if border:
|
|
463
|
+
parsed_border_color = parse_rgb(border)
|
|
464
|
+
if parsed_border_color:
|
|
465
|
+
all_colors.append(("card.border", border, parsed_border_color))
|
|
466
|
+
tokens["colors"]["border"] = color_entry(
|
|
467
|
+
"rgb({},{},{})".format(*parsed_border_color), "border"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
tokens["components"]["card"] = {
|
|
471
|
+
"bg": tokens["colors"].get("card", {}).get("tailwind", "card"),
|
|
472
|
+
"radius": tokens["radius"].get("card", {}).get("tailwind", "lg"),
|
|
473
|
+
"padding": tokens["spacing"].get("card_padding", {}).get("tailwind", "4"),
|
|
474
|
+
"shadow": shadow,
|
|
475
|
+
"border": border,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
# ---- Inputs ----
|
|
479
|
+
inputs = data.get("inputs", [])
|
|
480
|
+
if inputs:
|
|
481
|
+
inp = inputs[0]
|
|
482
|
+
inp_bg = inp.get("bg", "")
|
|
483
|
+
if inp_bg:
|
|
484
|
+
parsed = parse_rgb(inp_bg)
|
|
485
|
+
if parsed:
|
|
486
|
+
all_colors.append(("input.bg", inp_bg, parsed))
|
|
487
|
+
tokens["colors"]["input"] = color_entry(inp_bg, "input")
|
|
488
|
+
|
|
489
|
+
radius_px = parse_px(inp.get("borderRadius", ""))
|
|
490
|
+
if radius_px is not None:
|
|
491
|
+
tokens["radius"]["input"] = {"px": radius_px, "tailwind": px_to_tailwind_radius(radius_px)}
|
|
492
|
+
|
|
493
|
+
inp_border = inp.get("border", "")
|
|
494
|
+
if inp_border:
|
|
495
|
+
parsed_border = parse_rgb(inp_border)
|
|
496
|
+
if parsed_border:
|
|
497
|
+
all_colors.append(("input.border", inp_border, parsed_border))
|
|
498
|
+
if "border" not in tokens["colors"]:
|
|
499
|
+
tokens["colors"]["border"] = color_entry(
|
|
500
|
+
"rgb({},{},{})".format(*parsed_border), "border"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
tokens["components"]["input"] = {
|
|
504
|
+
"bg": tokens["colors"].get("input", {}).get("tailwind", "input"),
|
|
505
|
+
"radius": tokens["radius"].get("input", {}).get("tailwind", "md"),
|
|
506
|
+
"padding": parse_padding(inp.get("padding", "")),
|
|
507
|
+
"border": inp_border,
|
|
508
|
+
"fontSize": px_to_tailwind_font_size(parse_px(inp.get("fontSize", "14px")) or 14),
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
# ---- Nav ----
|
|
512
|
+
nav = data.get("nav", {})
|
|
513
|
+
if nav:
|
|
514
|
+
nav_bg = nav.get("bg", "")
|
|
515
|
+
if nav_bg:
|
|
516
|
+
parsed = parse_rgb(nav_bg)
|
|
517
|
+
if parsed:
|
|
518
|
+
all_colors.append(("nav.bg", nav_bg, parsed))
|
|
519
|
+
|
|
520
|
+
nav_shadow = nav.get("boxShadow", "")
|
|
521
|
+
if nav_shadow:
|
|
522
|
+
tokens["shadows"]["nav"] = nav_shadow
|
|
523
|
+
|
|
524
|
+
nav_border = nav.get("borderBottom", "")
|
|
525
|
+
if nav_border:
|
|
526
|
+
tokens["components"].setdefault("nav", {})["borderBottom"] = nav_border
|
|
527
|
+
|
|
528
|
+
height_px = parse_px(nav.get("height", ""))
|
|
529
|
+
if height_px is not None:
|
|
530
|
+
tokens["layout"]["navHeight"] = {
|
|
531
|
+
"px": height_px,
|
|
532
|
+
"tailwind": px_to_tailwind_spacing(height_px),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
tokens["components"].setdefault("nav", {}).update({
|
|
536
|
+
"bg": nav_bg,
|
|
537
|
+
"position": nav.get("position", "sticky"),
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
# ---- Layout ----
|
|
541
|
+
layout = data.get("layout", {})
|
|
542
|
+
if layout:
|
|
543
|
+
max_width = layout.get("maxWidth", "")
|
|
544
|
+
mw_px = parse_px(max_width)
|
|
545
|
+
if mw_px is not None:
|
|
546
|
+
tokens["layout"]["maxWidth"] = {
|
|
547
|
+
"px": mw_px,
|
|
548
|
+
"tailwind": px_to_tailwind_max_width(mw_px),
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
grid_cols = layout.get("gridColumns", "")
|
|
552
|
+
if grid_cols:
|
|
553
|
+
tokens["layout"]["gridColumns"] = grid_cols
|
|
554
|
+
|
|
555
|
+
# ---- Explicit colors section ----
|
|
556
|
+
colors_section = data.get("colors", {})
|
|
557
|
+
if colors_section:
|
|
558
|
+
bgs = colors_section.get("backgrounds", [])
|
|
559
|
+
for c_str in bgs:
|
|
560
|
+
parsed = parse_rgb(c_str)
|
|
561
|
+
if parsed:
|
|
562
|
+
all_colors.append(("colors.bg", c_str, parsed))
|
|
563
|
+
txts = colors_section.get("texts", [])
|
|
564
|
+
for c_str in txts:
|
|
565
|
+
parsed = parse_rgb(c_str)
|
|
566
|
+
if parsed:
|
|
567
|
+
all_colors.append(("colors.text", c_str, parsed))
|
|
568
|
+
|
|
569
|
+
# ---- Color clustering for any remaining unclassified colors ----
|
|
570
|
+
seen_roles = set(tokens["colors"].keys())
|
|
571
|
+
for context, rgb_str, rgb_tuple in all_colors:
|
|
572
|
+
role = classify_color_role(context, rgb_tuple)
|
|
573
|
+
if role not in seen_roles:
|
|
574
|
+
tokens["colors"][role] = color_entry(rgb_str, role)
|
|
575
|
+
seen_roles.add(role)
|
|
576
|
+
|
|
577
|
+
# ---- Page padding (from body/layout heuristic) ----
|
|
578
|
+
# If we don't have explicit page padding, try to infer from cards
|
|
579
|
+
if "page_padding" not in tokens["spacing"]:
|
|
580
|
+
# Default project page padding is 24px (p-6)
|
|
581
|
+
tokens["spacing"]["page_padding"] = {"px": 24, "tailwind": "6"}
|
|
582
|
+
|
|
583
|
+
return tokens
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
# 8. Entry point
|
|
588
|
+
# ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
def main():
|
|
591
|
+
if len(sys.argv) < 2:
|
|
592
|
+
print("Usage: {} <input.json>".format(sys.argv[0]), file=sys.stderr)
|
|
593
|
+
print(" Parses raw computed styles JSON into normalized design tokens.", file=sys.stderr)
|
|
594
|
+
sys.exit(1)
|
|
595
|
+
|
|
596
|
+
input_path = sys.argv[1]
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
with open(input_path, "r", encoding="utf-8") as f:
|
|
600
|
+
data = json.load(f)
|
|
601
|
+
except FileNotFoundError:
|
|
602
|
+
print("Error: File not found: {}".format(input_path), file=sys.stderr)
|
|
603
|
+
sys.exit(1)
|
|
604
|
+
except json.JSONDecodeError as e:
|
|
605
|
+
print("Error: Invalid JSON in {}: {}".format(input_path, e), file=sys.stderr)
|
|
606
|
+
sys.exit(1)
|
|
607
|
+
|
|
608
|
+
if not isinstance(data, dict):
|
|
609
|
+
print("Error: Expected top-level JSON object, got {}".format(type(data).__name__), file=sys.stderr)
|
|
610
|
+
sys.exit(1)
|
|
611
|
+
|
|
612
|
+
tokens = extract_tokens(data)
|
|
613
|
+
print(json.dumps(tokens, indent=2, ensure_ascii=False))
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
if __name__ == "__main__":
|
|
617
|
+
main()
|