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,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()