Files changed (3) hide show
  1. agents/llm_agents.py +9 -38
  2. app.py +205 -369
  3. core/rule_engine.py +6 -26
agents/llm_agents.py CHANGED
@@ -688,17 +688,8 @@ You are Agent 3 of 4 in the Design System Analysis pipeline.
688
  - Type Scale Standard Ratio: 10 points
689
  - Base Size Accessible: 15 points
690
  - Spacing Grid: 15 points
691
- - Color Count: 5 points
692
- - No Near-Duplicates: 5 points
693
- - Shadow System: 10 points (elevation hierarchy, consistency)
694
-
695
- ## SHADOW SYSTEM BEST PRACTICES:
696
- - Use 3-6 elevation levels (xs, sm, md, lg, xl, 2xl)
697
- - Consistent Y-offset progression (shadows should grow with elevation)
698
- - Blur radius should increase with elevation (more blur = higher elevation)
699
- - Shadow colors should be neutral (black/gray with alpha) or brand-colored with low opacity
700
- - Avoid shadows with 0 blur (looks harsh/flat)
701
- - Avoid excessive blur (>32px for most use cases)"""
702
 
703
  PROMPT_TEMPLATE = """Validate the following design tokens against best practices and prioritize fixes.
704
 
@@ -725,10 +716,6 @@ You are Agent 3 of 4 in the Design System Analysis pipeline.
725
  - Duplicates: {duplicates}
726
  - Near-Duplicates: {near_duplicates}
727
 
728
- ### Shadow System
729
- - Total Shadows: {shadow_count}
730
- - Shadow Values: {shadow_values}
731
-
732
  ## BEST PRACTICES CHECKLIST (check each one)
733
 
734
  1. Type scale uses standard ratio (1.2, 1.25, 1.333, 1.5, 1.618)
@@ -738,7 +725,6 @@ You are Agent 3 of 4 in the Design System Analysis pipeline.
738
  5. Spacing uses consistent grid (4px or 8px base)
739
  6. Limited color palette (< 20 unique semantic colors)
740
  7. No near-duplicate colors (< 3 delta-E apart)
741
- 8. Shadow system has consistent elevation hierarchy (blur/Y-offset increase together)
742
 
743
  ## YOUR TASK
744
 
@@ -758,8 +744,7 @@ You are Agent 3 of 4 in the Design System Analysis pipeline.
758
  "aa_compliance": {{"status": "...", "note": "..."}},
759
  "spacing_grid": {{"status": "...", "note": "..."}},
760
  "color_count": {{"status": "...", "note": "..."}},
761
- "near_duplicates": {{"status": "...", "note": "..."}},
762
- "shadow_system": {{"status": "...", "note": "Elevation hierarchy, blur consistency, color appropriateness"}}
763
  }},
764
  "priority_fixes": [
765
  {{
@@ -788,47 +773,35 @@ Return ONLY valid JSON."""
788
  async def analyze(
789
  self,
790
  rule_engine_results: Any,
791
- shadow_tokens: dict = None,
792
  log_callback: Callable = None,
793
  ) -> BestPracticesResult:
794
  """
795
  Validate against best practices.
796
-
797
  Args:
798
  rule_engine_results: Results from rule engine
799
- shadow_tokens: Shadow tokens dict {name: {value: "..."}}
800
  log_callback: Progress logging function
801
-
802
  Returns:
803
  BestPracticesResult with validation
804
  """
805
  def log(msg: str):
806
  if log_callback:
807
  log_callback(msg)
808
-
809
  log("")
810
  log(" ✅ SENTINEL — Best Practices Validator (Qwen 72B)")
811
  log(" └─ Checking against design system standards...")
812
-
813
  # Extract data from rule engine
814
  typo = rule_engine_results.typography
815
  spacing = rule_engine_results.spacing
816
  color_stats = rule_engine_results.color_stats
817
  accessibility = rule_engine_results.accessibility
818
-
819
  failures = [a for a in accessibility if not a.passes_aa_normal]
820
  failing_colors_str = ", ".join([f"{a.hex_color} ({a.contrast_on_white:.1f}:1)" for a in failures[:5]])
821
-
822
- # Format shadow data for the prompt
823
- shadow_count = len(shadow_tokens) if shadow_tokens else 0
824
- shadow_values_str = "None detected"
825
- if shadow_tokens and shadow_count > 0:
826
- shadow_list = []
827
- for name, s in list(shadow_tokens.items())[:6]:
828
- val = s.get("value", "") if isinstance(s, dict) else str(s)
829
- shadow_list.append(f"{name}: {val[:50]}")
830
- shadow_values_str = "; ".join(shadow_list)
831
-
832
  prompt = self.PROMPT_TEMPLATE.format(
833
  type_ratio=f"{typo.detected_ratio:.3f}",
834
  type_consistent="consistent" if typo.is_consistent else f"inconsistent, variance={typo.variance:.2f}",
@@ -844,8 +817,6 @@ Return ONLY valid JSON."""
844
  unique_colors=color_stats.unique_count,
845
  duplicates=color_stats.duplicate_count,
846
  near_duplicates=len(color_stats.near_duplicates),
847
- shadow_count=shadow_count,
848
- shadow_values=shadow_values_str,
849
  )
850
 
851
  try:
 
688
  - Type Scale Standard Ratio: 10 points
689
  - Base Size Accessible: 15 points
690
  - Spacing Grid: 15 points
691
+ - Color Count: 10 points
692
+ - No Near-Duplicates: 10 points"""
 
 
 
 
 
 
 
 
 
693
 
694
  PROMPT_TEMPLATE = """Validate the following design tokens against best practices and prioritize fixes.
695
 
 
716
  - Duplicates: {duplicates}
717
  - Near-Duplicates: {near_duplicates}
718
 
 
 
 
 
719
  ## BEST PRACTICES CHECKLIST (check each one)
720
 
721
  1. Type scale uses standard ratio (1.2, 1.25, 1.333, 1.5, 1.618)
 
725
  5. Spacing uses consistent grid (4px or 8px base)
726
  6. Limited color palette (< 20 unique semantic colors)
727
  7. No near-duplicate colors (< 3 delta-E apart)
 
728
 
729
  ## YOUR TASK
730
 
 
744
  "aa_compliance": {{"status": "...", "note": "..."}},
745
  "spacing_grid": {{"status": "...", "note": "..."}},
746
  "color_count": {{"status": "...", "note": "..."}},
747
+ "near_duplicates": {{"status": "...", "note": "..."}}
 
748
  }},
749
  "priority_fixes": [
750
  {{
 
773
  async def analyze(
774
  self,
775
  rule_engine_results: Any,
 
776
  log_callback: Callable = None,
777
  ) -> BestPracticesResult:
778
  """
779
  Validate against best practices.
780
+
781
  Args:
782
  rule_engine_results: Results from rule engine
 
783
  log_callback: Progress logging function
784
+
785
  Returns:
786
  BestPracticesResult with validation
787
  """
788
  def log(msg: str):
789
  if log_callback:
790
  log_callback(msg)
791
+
792
  log("")
793
  log(" ✅ SENTINEL — Best Practices Validator (Qwen 72B)")
794
  log(" └─ Checking against design system standards...")
795
+
796
  # Extract data from rule engine
797
  typo = rule_engine_results.typography
798
  spacing = rule_engine_results.spacing
799
  color_stats = rule_engine_results.color_stats
800
  accessibility = rule_engine_results.accessibility
801
+
802
  failures = [a for a in accessibility if not a.passes_aa_normal]
803
  failing_colors_str = ", ".join([f"{a.hex_color} ({a.contrast_on_white:.1f}:1)" for a in failures[:5]])
804
+
 
 
 
 
 
 
 
 
 
 
805
  prompt = self.PROMPT_TEMPLATE.format(
806
  type_ratio=f"{typo.detected_ratio:.3f}",
807
  type_consistent="consistent" if typo.is_consistent else f"inconsistent, variance={typo.variance:.2f}",
 
817
  unique_colors=color_stats.unique_count,
818
  duplicates=color_stats.duplicate_count,
819
  near_duplicates=len(color_stats.near_duplicates),
 
 
820
  )
821
 
822
  try:
app.py CHANGED
@@ -391,10 +391,9 @@ async def extract_tokens(pages_data, progress=gr.Progress()):
391
  confidence=Confidence.MEDIUM,
392
  )
393
 
394
- # Generate name based on color characteristics (not garbage like firecrawl.34)
395
- # This will be a fallback; semantic analysis may override later
396
- new_token.suggested_name = None # Let consolidation generate proper name
397
-
398
  state.desktop_normalized.colors[hex_val] = new_token
399
  new_colors_count += 1
400
 
@@ -1224,11 +1223,8 @@ async def run_stage2_analysis_v2(
1224
  async def _run_sentinel():
1225
  """Run SENTINEL (Best Practices Validator) agent."""
1226
  try:
1227
- # Get shadow tokens from desktop_dict for analysis
1228
- shadow_tokens = desktop_dict.get("shadows", {})
1229
  result = await best_practices_agent.analyze(
1230
  rule_engine_results=rule_results,
1231
- shadow_tokens=shadow_tokens,
1232
  log_callback=state.log,
1233
  )
1234
  if result:
@@ -2912,29 +2908,17 @@ def _get_radius_token_name(value_str, seen_names: dict = None) -> str:
2912
  except (ValueError, TypeError):
2913
  return "radius.md"
2914
 
2915
- # Handle percentage values (e.g., "50%" for circular)
2916
- if "%" in str(value_str):
2917
- base_name = "radius.full"
2918
- # "none" is ONLY for exactly 0px
2919
- elif px == 0:
2920
- base_name = "radius.none"
2921
- elif px >= 9999:
2922
- # Large values (like 9999px) are essentially "full"
2923
  base_name = "radius.full"
2924
  else:
2925
- # Semantic naming based on pixel ranges (inclusive both ends for clarity)
2926
  mapping = [
2927
- (1, 1, "radius.xs"), # 1px = xs
2928
- (2, 3, "radius.sm"), # 2-3px = sm
2929
- (4, 7, "radius.md"), # 4-7px = md
2930
- (8, 11, "radius.lg"), # 8-11px = lg
2931
- (12, 19, "radius.xl"), # 12-19px = xl
2932
- (20, 31, "radius.2xl"), # 20-31px = 2xl
2933
- (32, 99, "radius.3xl"), # 32-99px = 3xl
2934
  ]
2935
  base_name = "radius.md"
2936
  for low, high, name in mapping:
2937
- if low <= px <= high:
2938
  base_name = name
2939
  break
2940
 
@@ -2958,92 +2942,6 @@ def _get_shadow_blur(value_str: str) -> float:
2958
  return 0
2959
 
2960
 
2961
- def _parse_shadow_to_tokens_studio(value_str: str) -> dict:
2962
- """Parse CSS shadow string to Figma Tokens Studio boxShadow format.
2963
-
2964
- Input: "rgba(0, 0, 0, 0.5) 0px 2px 4px 0px" or "0px 2px 4px 0px rgba(0,0,0,0.5)"
2965
- Output: {"x": "0", "y": "2", "blur": "4", "spread": "0", "color": "rgba(0,0,0,0.5)", "type": "dropShadow"}
2966
- """
2967
- import re
2968
- value_str = str(value_str).strip()
2969
-
2970
- # Extract color (rgba/rgb/hex)
2971
- color_match = re.search(r'(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8})', value_str)
2972
- color = color_match.group(1) if color_match else "rgba(0,0,0,0.25)"
2973
-
2974
- # Extract px values
2975
- px_values = re.findall(r'(-?[\d.]+)px', value_str)
2976
-
2977
- # Standard order: x y blur spread
2978
- x = px_values[0] if len(px_values) > 0 else "0"
2979
- y = px_values[1] if len(px_values) > 1 else "0"
2980
- blur = px_values[2] if len(px_values) > 2 else "0"
2981
- spread = px_values[3] if len(px_values) > 3 else "0"
2982
-
2983
- # Determine if inset
2984
- shadow_type = "innerShadow" if "inset" in value_str.lower() else "dropShadow"
2985
-
2986
- return {
2987
- "x": x,
2988
- "y": y,
2989
- "blur": blur,
2990
- "spread": spread,
2991
- "color": color,
2992
- "type": shadow_type,
2993
- }
2994
-
2995
-
2996
- # =============================================================================
2997
- # W3C DTCG FORMAT HELPERS
2998
- # =============================================================================
2999
-
3000
- def _flat_key_to_nested(flat_key: str, value: dict, root: dict):
3001
- """Convert 'color.brand.primary' into nested dict structure.
3002
-
3003
- Example: _flat_key_to_nested('color.brand.primary', token, {})
3004
- Result: {'color': {'brand': {'primary': token}}}
3005
- """
3006
- parts = flat_key.split('.')
3007
- current = root
3008
- for part in parts[:-1]:
3009
- if part not in current:
3010
- current[part] = {}
3011
- current = current[part]
3012
- current[parts[-1]] = value
3013
-
3014
-
3015
- def _to_dtcg_token(value, token_type: str, description: str = None, source: str = None) -> dict:
3016
- """Wrap value in W3C DTCG format with $value, $type, $description.
3017
-
3018
- Args:
3019
- value: The token value
3020
- token_type: W3C DTCG type (color, typography, dimension, shadow)
3021
- description: Optional human-readable description
3022
- source: Optional source indicator (extracted, recommended, semantic)
3023
- """
3024
- token = {"$type": token_type, "$value": value}
3025
- if description:
3026
- token["$description"] = description
3027
- if source:
3028
- token["$description"] = f"[{source}] {description or ''}"
3029
- return token
3030
-
3031
-
3032
- def _shadow_to_dtcg(shadow_dict: dict) -> dict:
3033
- """Convert our internal shadow format to W3C DTCG shadow spec.
3034
-
3035
- Input: {"x": "0", "y": "2", "blur": "4", "spread": "0", "color": "rgba(...)"}
3036
- Output: {"color": "...", "offsetX": "0px", "offsetY": "2px", "blur": "4px", "spread": "0px"}
3037
- """
3038
- return {
3039
- "color": shadow_dict.get("color", "rgba(0,0,0,0.25)"),
3040
- "offsetX": str(shadow_dict.get("x", "0")) + "px",
3041
- "offsetY": str(shadow_dict.get("y", "0")) + "px",
3042
- "blur": str(shadow_dict.get("blur", "0")) + "px",
3043
- "spread": str(shadow_dict.get("spread", "0")) + "px",
3044
- }
3045
-
3046
-
3047
  def _get_semantic_color_overrides() -> dict:
3048
  """Build color hex→semantic name map from semantic analysis + LLM results."""
3049
  overrides = {} # hex → semantic_name
@@ -3117,104 +3015,6 @@ def _get_semantic_color_overrides() -> dict:
3117
  return overrides
3118
 
3119
 
3120
- def _is_valid_hex_color(value: str) -> bool:
3121
- """Validate that a string is a proper hex color (not CSS garbage)."""
3122
- import re
3123
- if not value or not isinstance(value, str):
3124
- return False
3125
- # Must be exactly #RGB, #RRGGBB, or #RRGGBBAA
3126
- clean = value.strip().lower()
3127
- return bool(re.match(r'^#([a-f0-9]{3}|[a-f0-9]{6}|[a-f0-9]{8})$', clean))
3128
-
3129
-
3130
- def _generate_color_name_from_hex(hex_val: str, used_names: set = None) -> str:
3131
- """Generate a semantic color name based on the color's HSL characteristics.
3132
-
3133
- Returns names like: color.neutral.400, color.blue.500, color.red.300
3134
- Uses standard design system naming conventions.
3135
- """
3136
- import colorsys
3137
-
3138
- used_names = used_names or set()
3139
-
3140
- # Parse hex to RGB
3141
- hex_clean = hex_val.lstrip('#').lower()
3142
- if len(hex_clean) == 3:
3143
- hex_clean = ''.join([c*2 for c in hex_clean])
3144
-
3145
- try:
3146
- r = int(hex_clean[0:2], 16) / 255
3147
- g = int(hex_clean[2:4], 16) / 255
3148
- b = int(hex_clean[4:6], 16) / 255
3149
- except (ValueError, IndexError):
3150
- return "color.other.base"
3151
-
3152
- # Convert to HSL
3153
- h, l, s = colorsys.rgb_to_hls(r, g, b)
3154
- hue = h * 360
3155
- saturation = s
3156
- lightness = l
3157
-
3158
- # Determine color family based on hue (for saturated colors)
3159
- if saturation < 0.1:
3160
- # Grayscale / neutral
3161
- color_family = "neutral"
3162
- else:
3163
- # Map hue to color name
3164
- if hue < 15 or hue >= 345:
3165
- color_family = "red"
3166
- elif hue < 45:
3167
- color_family = "orange"
3168
- elif hue < 75:
3169
- color_family = "yellow"
3170
- elif hue < 150:
3171
- color_family = "green"
3172
- elif hue < 195:
3173
- color_family = "teal"
3174
- elif hue < 255:
3175
- color_family = "blue"
3176
- elif hue < 285:
3177
- color_family = "purple"
3178
- elif hue < 345:
3179
- color_family = "pink"
3180
- else:
3181
- color_family = "red"
3182
-
3183
- # Determine shade based on lightness (100-900 scale)
3184
- if lightness >= 0.95:
3185
- shade = "50"
3186
- elif lightness >= 0.85:
3187
- shade = "100"
3188
- elif lightness >= 0.75:
3189
- shade = "200"
3190
- elif lightness >= 0.65:
3191
- shade = "300"
3192
- elif lightness >= 0.50:
3193
- shade = "400"
3194
- elif lightness >= 0.40:
3195
- shade = "500"
3196
- elif lightness >= 0.30:
3197
- shade = "600"
3198
- elif lightness >= 0.20:
3199
- shade = "700"
3200
- elif lightness >= 0.10:
3201
- shade = "800"
3202
- else:
3203
- shade = "900"
3204
-
3205
- # Generate base name
3206
- base_name = f"color.{color_family}.{shade}"
3207
-
3208
- # Handle conflicts by adding suffix
3209
- final_name = base_name
3210
- suffix = 1
3211
- while final_name in used_names:
3212
- suffix += 1
3213
- final_name = f"{base_name}_{suffix}"
3214
-
3215
- return final_name
3216
-
3217
-
3218
  def _consolidate_colors(colors_dict: dict, overrides: dict, max_colors: int = 30) -> dict:
3219
  """Consolidate colors: semantic first, then top by frequency, capped."""
3220
  if not colors_dict:
@@ -3222,15 +3022,9 @@ def _consolidate_colors(colors_dict: dict, overrides: dict, max_colors: int = 30
3222
 
3223
  result = {}
3224
  remaining = []
3225
- used_generated_names = set() # Track generated names to avoid conflicts
3226
 
3227
  for name, c in colors_dict.items():
3228
  hex_val = c.value.lower() if hasattr(c, 'value') else str(c.get('value', '')).lower()
3229
-
3230
- # IMPORTANT: Skip invalid/garbage color values (CSS parsing errors)
3231
- if not _is_valid_hex_color(hex_val):
3232
- continue
3233
-
3234
  freq = c.frequency if hasattr(c, 'frequency') else c.get('frequency', 0)
3235
 
3236
  # Check if this color has a semantic override
@@ -3242,24 +3036,11 @@ def _consolidate_colors(colors_dict: dict, overrides: dict, max_colors: int = 30
3242
  "source": "semantic",
3243
  }
3244
  else:
3245
- # Check for garbage names (firecrawl.N, numeric-only, etc.)
3246
  base_name = (c.suggested_name if hasattr(c, 'suggested_name') else name) or name
3247
  clean_name = base_name.replace(" ", ".").replace("_", ".").lower()
3248
-
3249
- # Detect garbage names and generate proper color-based names
3250
- is_garbage_name = (
3251
- 'firecrawl' in clean_name.lower() or
3252
- clean_name.split('.')[-1].isdigit() or # Ends with just a number
3253
- len(clean_name.split('.')) == 2 and clean_name.split('.')[-1].isdigit() # color.34
3254
- )
3255
-
3256
- if is_garbage_name:
3257
- # Generate proper name based on color characteristics
3258
- clean_name = _generate_color_name_from_hex(hex_val, used_generated_names)
3259
- used_generated_names.add(clean_name)
3260
- elif not clean_name.startswith("color."):
3261
  clean_name = f"color.{clean_name}"
3262
-
3263
  remaining.append((clean_name, hex_val, freq))
3264
 
3265
  # Sort remaining by frequency (highest first), take up to max
@@ -3277,7 +3058,7 @@ def _consolidate_colors(colors_dict: dict, overrides: dict, max_colors: int = 30
3277
 
3278
 
3279
  def export_stage1_json():
3280
- """Export Stage 1 tokens (as-is extraction) to W3C DTCG format."""
3281
  if not state.desktop_normalized:
3282
  gr.Warning("No tokens extracted yet. Complete Stage 1 extraction first.")
3283
  return json.dumps({
@@ -3285,29 +3066,44 @@ def export_stage1_json():
3285
  "how_to_fix": "Go to Step 1, enter a URL, discover pages, and extract tokens first.",
3286
  "stage": "Stage 1 required"
3287
  }, indent=2)
3288
-
3289
- # W3C DTCG format: nested structure, no wrapper, $value/$type
3290
- result = {}
3291
- token_count = 0
3292
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3293
  # =========================================================================
3294
- # COLORS — Nested structure with $value, $type, $description
 
 
 
 
 
 
 
3295
  # =========================================================================
3296
  if state.desktop_normalized and state.desktop_normalized.colors:
3297
  overrides = _get_semantic_color_overrides()
3298
- consolidated = _consolidate_colors(
3299
  state.desktop_normalized.colors, overrides, max_colors=30,
3300
  )
3301
- for flat_key, entry in consolidated.items():
3302
- # flat_key = "color.brand.primary"
3303
- source = entry.get("source", "extracted")
3304
- source_label = "LLM Semantic" if source == "semantic" else "Auto-Generated" if source == "detected" else "Extracted"
3305
- dtcg_token = _to_dtcg_token(entry["value"], "color", description=source_label)
3306
- _flat_key_to_nested(flat_key, dtcg_token, result)
3307
- token_count += 1
3308
 
3309
  # =========================================================================
3310
- # TYPOGRAPHY Nested structure with viewport suffix
3311
  # =========================================================================
3312
  # Desktop typography
3313
  if state.desktop_normalized and state.desktop_normalized.typography:
@@ -3316,18 +3112,19 @@ def export_stage1_json():
3316
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3317
  if not clean_name.startswith("font."):
3318
  clean_name = f"font.{clean_name}"
3319
-
3320
- flat_key = f"{clean_name}.desktop"
3321
- typo_value = {
 
 
 
 
3322
  "fontFamily": t.font_family,
3323
- "fontSize": t.font_size,
3324
  "fontWeight": str(t.font_weight),
3325
  "lineHeight": t.line_height or "1.5",
 
3326
  }
3327
- dtcg_token = _to_dtcg_token(typo_value, "typography", description="Extracted from site")
3328
- _flat_key_to_nested(flat_key, dtcg_token, result)
3329
- token_count += 1
3330
-
3331
  # Mobile typography
3332
  if state.mobile_normalized and state.mobile_normalized.typography:
3333
  for name, t in state.mobile_normalized.typography.items():
@@ -3335,20 +3132,21 @@ def export_stage1_json():
3335
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3336
  if not clean_name.startswith("font."):
3337
  clean_name = f"font.{clean_name}"
3338
-
3339
- flat_key = f"{clean_name}.mobile"
3340
- typo_value = {
 
 
 
 
3341
  "fontFamily": t.font_family,
3342
- "fontSize": t.font_size,
3343
  "fontWeight": str(t.font_weight),
3344
  "lineHeight": t.line_height or "1.5",
 
3345
  }
3346
- dtcg_token = _to_dtcg_token(typo_value, "typography", description="Extracted from site")
3347
- _flat_key_to_nested(flat_key, dtcg_token, result)
3348
- token_count += 1
3349
-
3350
  # =========================================================================
3351
- # SPACING Nested structure with viewport suffix
3352
  # =========================================================================
3353
  # Desktop spacing
3354
  if state.desktop_normalized and state.desktop_normalized.spacing:
@@ -3357,12 +3155,16 @@ def export_stage1_json():
3357
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3358
  if not clean_name.startswith("space."):
3359
  clean_name = f"space.{clean_name}"
3360
-
3361
- flat_key = f"{clean_name}.desktop"
3362
- dtcg_token = _to_dtcg_token(s.value, "dimension", description="Extracted from site")
3363
- _flat_key_to_nested(flat_key, dtcg_token, result)
3364
- token_count += 1
3365
-
 
 
 
 
3366
  # Mobile spacing
3367
  if state.mobile_normalized and state.mobile_normalized.spacing:
3368
  for name, s in state.mobile_normalized.spacing.items():
@@ -3370,46 +3172,49 @@ def export_stage1_json():
3370
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3371
  if not clean_name.startswith("space."):
3372
  clean_name = f"space.{clean_name}"
3373
-
3374
- flat_key = f"{clean_name}.mobile"
3375
- dtcg_token = _to_dtcg_token(s.value, "dimension", description="Extracted from site")
3376
- _flat_key_to_nested(flat_key, dtcg_token, result)
3377
- token_count += 1
3378
-
 
 
 
 
3379
  # =========================================================================
3380
- # BORDER RADIUS — Nested structure (DTCG uses "dimension" type for radii)
3381
  # =========================================================================
3382
  if state.desktop_normalized and state.desktop_normalized.radius:
3383
  seen_radius = {}
3384
  for name, r in state.desktop_normalized.radius.items():
3385
  token_name = _get_radius_token_name(r.value, seen_radius)
3386
- # Convert "radius.md" to nested: radius.md (keep as "radius" for consistency)
3387
- flat_key = token_name
3388
- dtcg_token = _to_dtcg_token(r.value, "dimension", description="Extracted from site")
3389
- _flat_key_to_nested(flat_key, dtcg_token, result)
3390
- token_count += 1
3391
 
3392
  # =========================================================================
3393
- # SHADOWS — W3C DTCG shadow format
3394
  # =========================================================================
3395
  if state.desktop_normalized and state.desktop_normalized.shadows:
3396
- shadow_names = ["xs", "sm", "md", "lg", "xl", "2xl"]
3397
  sorted_shadows = sorted(
3398
  state.desktop_normalized.shadows.items(),
3399
  key=lambda x: _get_shadow_blur(x[1].value),
3400
  )
3401
  for i, (name, s) in enumerate(sorted_shadows):
3402
- size_name = shadow_names[i] if i < len(shadow_names) else str(i + 1)
3403
- flat_key = f"shadow.{size_name}"
3404
- # Parse CSS shadow and convert to DTCG format
3405
- parsed = _parse_shadow_to_tokens_studio(s.value)
3406
- dtcg_shadow_value = _shadow_to_dtcg(parsed)
3407
- dtcg_token = _to_dtcg_token(dtcg_shadow_value, "shadow", description="Extracted from site")
3408
- _flat_key_to_nested(flat_key, dtcg_token, result)
3409
- token_count += 1
3410
 
3411
  json_str = json.dumps(result, indent=2, default=str)
3412
- gr.Info(f"Stage 1 exported: {token_count} tokens (W3C DTCG format)")
 
3413
  return json_str
3414
 
3415
 
@@ -3447,11 +3252,36 @@ def export_tokens_json():
3447
  elif "4px" in spacing_choice:
3448
  spacing_base = 4
3449
 
3450
- # W3C DTCG format: nested structure, no wrapper
3451
- result = {}
3452
- token_count = 0
3453
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3454
  fonts_info = get_detected_fonts()
 
 
 
 
3455
  primary_font = fonts_info.get("primary", "sans-serif")
3456
 
3457
  # =========================================================================
@@ -3465,26 +3295,23 @@ def export_tokens_json():
3465
  state.desktop_normalized.colors, overrides, max_colors=30,
3466
  )
3467
 
3468
- for flat_key, entry in consolidated.items():
3469
  if apply_ramps:
3470
  try:
3471
  ramp = generate_color_ramp(entry["value"])
3472
  shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"]
3473
  for i, shade in enumerate(shades):
3474
  if i < len(ramp):
3475
- shade_key = f"{flat_key}.{shade}"
3476
- color_val = ramp[i] if isinstance(ramp[i], str) else ramp[i].get("hex", entry["value"])
3477
- dtcg_token = _to_dtcg_token(color_val, "color")
3478
- _flat_key_to_nested(shade_key, dtcg_token, result)
3479
- token_count += 1
 
3480
  except (ValueError, KeyError, TypeError, IndexError):
3481
- dtcg_token = _to_dtcg_token(entry["value"], "color")
3482
- _flat_key_to_nested(flat_key, dtcg_token, result)
3483
- token_count += 1
3484
  else:
3485
- dtcg_token = _to_dtcg_token(entry["value"], "color")
3486
- _flat_key_to_nested(flat_key, dtcg_token, result)
3487
- token_count += 1
3488
 
3489
  # =========================================================================
3490
  # TYPOGRAPHY - FLAT structure with viewport suffix
@@ -3513,137 +3340,145 @@ def export_tokens_json():
3513
  return tier
3514
  return "body"
3515
 
3516
- # Desktop typography — W3C DTCG format
3517
  if ratio:
 
3518
  scales = [int(round(base_size * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
3519
  for i, token_name in enumerate(token_names):
3520
  tier = _tier_from_token(token_name)
3521
- flat_key = f"{token_name}.desktop"
3522
- typo_value = {
 
 
3523
  "fontFamily": primary_font,
3524
- "fontSize": f"{scales[i]}px",
3525
  "fontWeight": _weight_map.get(tier, "400"),
3526
  "lineHeight": _lh_map.get(tier, "1.5"),
 
3527
  }
3528
- dtcg_token = _to_dtcg_token(typo_value, "typography")
3529
- _flat_key_to_nested(flat_key, dtcg_token, result)
3530
- token_count += 1
3531
  elif state.desktop_normalized and state.desktop_normalized.typography:
 
3532
  for name, t in state.desktop_normalized.typography.items():
3533
  base_name = t.suggested_name or name
3534
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3535
  if not clean_name.startswith("font."):
3536
  clean_name = f"font.{clean_name}"
3537
-
3538
- flat_key = f"{clean_name}.desktop"
3539
- typo_value = {
 
 
3540
  "fontFamily": t.font_family,
3541
- "fontSize": t.font_size,
3542
  "fontWeight": str(t.font_weight),
3543
  "lineHeight": t.line_height or "1.5",
 
3544
  }
3545
- dtcg_token = _to_dtcg_token(typo_value, "typography")
3546
- _flat_key_to_nested(flat_key, dtcg_token, result)
3547
- token_count += 1
3548
-
3549
- # Mobile typography — W3C DTCG format
3550
  if ratio:
 
3551
  mobile_factor = 0.875
3552
  scales = [int(round(base_size * mobile_factor * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
3553
  for i, token_name in enumerate(token_names):
3554
  tier = _tier_from_token(token_name)
3555
- flat_key = f"{token_name}.mobile"
3556
- typo_value = {
 
 
3557
  "fontFamily": primary_font,
3558
- "fontSize": f"{scales[i]}px",
3559
  "fontWeight": _weight_map.get(tier, "400"),
3560
  "lineHeight": _lh_map.get(tier, "1.5"),
 
3561
  }
3562
- dtcg_token = _to_dtcg_token(typo_value, "typography")
3563
- _flat_key_to_nested(flat_key, dtcg_token, result)
3564
- token_count += 1
3565
  elif state.mobile_normalized and state.mobile_normalized.typography:
3566
  for name, t in state.mobile_normalized.typography.items():
3567
  base_name = t.suggested_name or name
3568
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3569
  if not clean_name.startswith("font."):
3570
  clean_name = f"font.{clean_name}"
3571
-
3572
- flat_key = f"{clean_name}.mobile"
3573
- typo_value = {
 
 
3574
  "fontFamily": t.font_family,
3575
- "fontSize": t.font_size,
3576
  "fontWeight": str(t.font_weight),
3577
  "lineHeight": t.line_height or "1.5",
 
3578
  }
3579
- dtcg_token = _to_dtcg_token(typo_value, "typography")
3580
- _flat_key_to_nested(flat_key, dtcg_token, result)
3581
- token_count += 1
3582
 
3583
  # =========================================================================
3584
- # SPACING W3C DTCG format with nested structure
3585
  # =========================================================================
3586
  spacing_token_names = [
3587
  "space.1", "space.2", "space.3", "space.4", "space.5",
3588
  "space.6", "space.8", "space.10", "space.12", "space.16"
3589
  ]
3590
-
3591
  if spacing_base:
3592
  # Generate grid-aligned spacing for both viewports
3593
  for i, token_name in enumerate(spacing_token_names):
3594
  value = spacing_base * (i + 1)
3595
-
3596
  # Desktop
3597
  desktop_key = f"{token_name}.desktop"
3598
- dtcg_token = _to_dtcg_token(f"{value}px", "dimension")
3599
- _flat_key_to_nested(desktop_key, dtcg_token, result)
3600
- token_count += 1
3601
-
 
 
3602
  # Mobile (same values)
3603
  mobile_key = f"{token_name}.mobile"
3604
- dtcg_token = _to_dtcg_token(f"{value}px", "dimension")
3605
- _flat_key_to_nested(mobile_key, dtcg_token, result)
3606
- token_count += 1
 
 
3607
  else:
3608
- # Keep original with nested structure
3609
  if state.desktop_normalized and state.desktop_normalized.spacing:
3610
  for name, s in state.desktop_normalized.spacing.items():
3611
  base_name = s.suggested_name or name
3612
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3613
  if not clean_name.startswith("space."):
3614
  clean_name = f"space.{clean_name}"
3615
-
3616
  desktop_key = f"{clean_name}.desktop"
3617
- dtcg_token = _to_dtcg_token(s.value, "dimension")
3618
- _flat_key_to_nested(desktop_key, dtcg_token, result)
3619
- token_count += 1
3620
-
 
 
3621
  if state.mobile_normalized and state.mobile_normalized.spacing:
3622
  for name, s in state.mobile_normalized.spacing.items():
3623
  base_name = s.suggested_name or name
3624
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3625
  if not clean_name.startswith("space."):
3626
  clean_name = f"space.{clean_name}"
3627
-
3628
  mobile_key = f"{clean_name}.mobile"
3629
- dtcg_token = _to_dtcg_token(s.value, "dimension")
3630
- _flat_key_to_nested(mobile_key, dtcg_token, result)
3631
- token_count += 1
3632
-
 
 
3633
  # =========================================================================
3634
- # BORDER RADIUS — W3C DTCG format (uses "dimension" type per spec)
3635
  # =========================================================================
3636
  if state.desktop_normalized and state.desktop_normalized.radius:
3637
  seen_radius = {}
3638
  for name, r in state.desktop_normalized.radius.items():
3639
  token_name = _get_radius_token_name(r.value, seen_radius)
3640
- # DTCG uses "dimension" for radii, not "borderRadius"
3641
- dtcg_token = _to_dtcg_token(r.value, "dimension")
3642
- _flat_key_to_nested(token_name, dtcg_token, result)
3643
- token_count += 1
 
3644
 
3645
  # =========================================================================
3646
- # SHADOWS — W3C DTCG format with shadow spec
3647
  # =========================================================================
3648
  if state.desktop_normalized and state.desktop_normalized.shadows:
3649
  shadow_names = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
@@ -3653,15 +3488,16 @@ def export_tokens_json():
3653
  )
3654
  for i, (name, s) in enumerate(sorted_shadows):
3655
  token_name = shadow_names[i] if i < len(shadow_names) else f"shadow.{i + 1}"
3656
- # Convert to DTCG shadow format
3657
- shadow_value = _shadow_to_dtcg(_parse_shadow_to_tokens_studio(s.value))
3658
- dtcg_token = _to_dtcg_token(shadow_value, "shadow")
3659
- _flat_key_to_nested(token_name, dtcg_token, result)
3660
- token_count += 1
3661
 
3662
  json_str = json.dumps(result, indent=2, default=str)
 
3663
  upgrades_note = " (with upgrades)" if upgrades else " (no upgrades applied)"
3664
- gr.Info(f"Final export: {token_count} tokens{upgrades_note}")
3665
  return json_str
3666
 
3667
 
 
391
  confidence=Confidence.MEDIUM,
392
  )
393
 
394
+ # Generate name
395
+ new_token.suggested_name = f"color.firecrawl.{len(state.desktop_normalized.colors)}"
396
+
 
397
  state.desktop_normalized.colors[hex_val] = new_token
398
  new_colors_count += 1
399
 
 
1223
  async def _run_sentinel():
1224
  """Run SENTINEL (Best Practices Validator) agent."""
1225
  try:
 
 
1226
  result = await best_practices_agent.analyze(
1227
  rule_engine_results=rule_results,
 
1228
  log_callback=state.log,
1229
  )
1230
  if result:
 
2908
  except (ValueError, TypeError):
2909
  return "radius.md"
2910
 
2911
+ if "%" in str(value_str) or px >= 50:
 
 
 
 
 
 
 
2912
  base_name = "radius.full"
2913
  else:
 
2914
  mapping = [
2915
+ (0, 2, "radius.none"), (2, 4, "radius.xs"), (4, 6, "radius.sm"),
2916
+ (6, 10, "radius.md"), (10, 16, "radius.lg"), (16, 32, "radius.xl"),
2917
+ (32, 100, "radius.2xl"),
 
 
 
 
2918
  ]
2919
  base_name = "radius.md"
2920
  for low, high, name in mapping:
2921
+ if low <= px < high:
2922
  base_name = name
2923
  break
2924
 
 
2942
  return 0
2943
 
2944
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2945
  def _get_semantic_color_overrides() -> dict:
2946
  """Build color hex→semantic name map from semantic analysis + LLM results."""
2947
  overrides = {} # hex → semantic_name
 
3015
  return overrides
3016
 
3017
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3018
  def _consolidate_colors(colors_dict: dict, overrides: dict, max_colors: int = 30) -> dict:
3019
  """Consolidate colors: semantic first, then top by frequency, capped."""
3020
  if not colors_dict:
 
3022
 
3023
  result = {}
3024
  remaining = []
 
3025
 
3026
  for name, c in colors_dict.items():
3027
  hex_val = c.value.lower() if hasattr(c, 'value') else str(c.get('value', '')).lower()
 
 
 
 
 
3028
  freq = c.frequency if hasattr(c, 'frequency') else c.get('frequency', 0)
3029
 
3030
  # Check if this color has a semantic override
 
3036
  "source": "semantic",
3037
  }
3038
  else:
3039
+ # Use normalizer's suggested name
3040
  base_name = (c.suggested_name if hasattr(c, 'suggested_name') else name) or name
3041
  clean_name = base_name.replace(" ", ".").replace("_", ".").lower()
3042
+ if not clean_name.startswith("color."):
 
 
 
 
 
 
 
 
 
 
 
 
3043
  clean_name = f"color.{clean_name}"
 
3044
  remaining.append((clean_name, hex_val, freq))
3045
 
3046
  # Sort remaining by frequency (highest first), take up to max
 
3058
 
3059
 
3060
  def export_stage1_json():
3061
+ """Export Stage 1 tokens (as-is extraction) to JSON - FLAT structure for Figma Tokens Studio."""
3062
  if not state.desktop_normalized:
3063
  gr.Warning("No tokens extracted yet. Complete Stage 1 extraction first.")
3064
  return json.dumps({
 
3066
  "how_to_fix": "Go to Step 1, enter a URL, discover pages, and extract tokens first.",
3067
  "stage": "Stage 1 required"
3068
  }, indent=2)
3069
+
3070
+ # FLAT structure for Figma Tokens Studio compatibility
3071
+ result = {
3072
+ "metadata": {
3073
+ "source_url": state.base_url,
3074
+ "extracted_at": datetime.now().isoformat(),
3075
+ "version": "v1-stage1-as-is",
3076
+ "stage": "extraction",
3077
+ "description": "Raw extracted tokens before upgrades - Figma Tokens Studio compatible",
3078
+ },
3079
+ "fonts": {},
3080
+ "colors": {},
3081
+ "typography": {}, # FLAT: font.display.xl.desktop, font.display.xl.mobile
3082
+ "spacing": {}, # FLAT: space.1.desktop, space.1.mobile
3083
+ "radius": {},
3084
+ "shadows": {},
3085
+ }
3086
+
3087
+ # =========================================================================
3088
+ # FONTS
3089
  # =========================================================================
3090
+ fonts_info = get_detected_fonts()
3091
+ result["fonts"] = {
3092
+ "primary": fonts_info.get("primary", "Unknown"),
3093
+ "weights": fonts_info.get("weights", [400]),
3094
+ }
3095
+
3096
+ # =========================================================================
3097
+ # COLORS — Consolidated with semantic names (capped ~30)
3098
  # =========================================================================
3099
  if state.desktop_normalized and state.desktop_normalized.colors:
3100
  overrides = _get_semantic_color_overrides()
3101
+ result["colors"] = _consolidate_colors(
3102
  state.desktop_normalized.colors, overrides, max_colors=30,
3103
  )
 
 
 
 
 
 
 
3104
 
3105
  # =========================================================================
3106
+ # TYPOGRAPHY - FLAT structure with viewport suffix
3107
  # =========================================================================
3108
  # Desktop typography
3109
  if state.desktop_normalized and state.desktop_normalized.typography:
 
3112
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3113
  if not clean_name.startswith("font."):
3114
  clean_name = f"font.{clean_name}"
3115
+
3116
+ # Add .desktop suffix
3117
+ token_key = f"{clean_name}.desktop"
3118
+
3119
+ result["typography"][token_key] = {
3120
+ "value": t.font_size,
3121
+ "type": "dimension",
3122
  "fontFamily": t.font_family,
 
3123
  "fontWeight": str(t.font_weight),
3124
  "lineHeight": t.line_height or "1.5",
3125
+ "source": "detected",
3126
  }
3127
+
 
 
 
3128
  # Mobile typography
3129
  if state.mobile_normalized and state.mobile_normalized.typography:
3130
  for name, t in state.mobile_normalized.typography.items():
 
3132
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3133
  if not clean_name.startswith("font."):
3134
  clean_name = f"font.{clean_name}"
3135
+
3136
+ # Add .mobile suffix
3137
+ token_key = f"{clean_name}.mobile"
3138
+
3139
+ result["typography"][token_key] = {
3140
+ "value": t.font_size,
3141
+ "type": "dimension",
3142
  "fontFamily": t.font_family,
 
3143
  "fontWeight": str(t.font_weight),
3144
  "lineHeight": t.line_height or "1.5",
3145
+ "source": "detected",
3146
  }
3147
+
 
 
 
3148
  # =========================================================================
3149
+ # SPACING - FLAT structure with viewport suffix
3150
  # =========================================================================
3151
  # Desktop spacing
3152
  if state.desktop_normalized and state.desktop_normalized.spacing:
 
3155
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3156
  if not clean_name.startswith("space."):
3157
  clean_name = f"space.{clean_name}"
3158
+
3159
+ # Add .desktop suffix
3160
+ token_key = f"{clean_name}.desktop"
3161
+
3162
+ result["spacing"][token_key] = {
3163
+ "value": s.value,
3164
+ "type": "dimension",
3165
+ "source": "detected",
3166
+ }
3167
+
3168
  # Mobile spacing
3169
  if state.mobile_normalized and state.mobile_normalized.spacing:
3170
  for name, s in state.mobile_normalized.spacing.items():
 
3172
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3173
  if not clean_name.startswith("space."):
3174
  clean_name = f"space.{clean_name}"
3175
+
3176
+ # Add .mobile suffix
3177
+ token_key = f"{clean_name}.mobile"
3178
+
3179
+ result["spacing"][token_key] = {
3180
+ "value": s.value,
3181
+ "type": "dimension",
3182
+ "source": "detected",
3183
+ }
3184
+
3185
  # =========================================================================
3186
+ # RADIUS — Semantic names (radius.sm, radius.md, radius.lg)
3187
  # =========================================================================
3188
  if state.desktop_normalized and state.desktop_normalized.radius:
3189
  seen_radius = {}
3190
  for name, r in state.desktop_normalized.radius.items():
3191
  token_name = _get_radius_token_name(r.value, seen_radius)
3192
+ result["radius"][token_name] = {
3193
+ "value": r.value,
3194
+ "type": "dimension",
3195
+ "source": "detected",
3196
+ }
3197
 
3198
  # =========================================================================
3199
+ # SHADOWS — Semantic names (shadow.xs, shadow.sm, shadow.md)
3200
  # =========================================================================
3201
  if state.desktop_normalized and state.desktop_normalized.shadows:
3202
+ shadow_names = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
3203
  sorted_shadows = sorted(
3204
  state.desktop_normalized.shadows.items(),
3205
  key=lambda x: _get_shadow_blur(x[1].value),
3206
  )
3207
  for i, (name, s) in enumerate(sorted_shadows):
3208
+ token_name = shadow_names[i] if i < len(shadow_names) else f"shadow.{i + 1}"
3209
+ result["shadows"][token_name] = {
3210
+ "value": s.value,
3211
+ "type": "boxShadow",
3212
+ "source": "detected",
3213
+ }
 
 
3214
 
3215
  json_str = json.dumps(result, indent=2, default=str)
3216
+ token_count = sum(len(v) for k, v in result.items() if isinstance(v, dict) and k != "metadata")
3217
+ gr.Info(f"Stage 1 exported: {token_count} tokens across {len(result) - 1} categories")
3218
  return json_str
3219
 
3220
 
 
3252
  elif "4px" in spacing_choice:
3253
  spacing_base = 4
3254
 
3255
+ # FLAT structure for Figma Tokens Studio compatibility
3256
+ result = {
3257
+ "metadata": {
3258
+ "source_url": state.base_url,
3259
+ "extracted_at": datetime.now().isoformat(),
3260
+ "version": "v2-upgraded",
3261
+ "stage": "final",
3262
+ "description": "Upgraded tokens - Figma Tokens Studio compatible",
3263
+ "upgrades_applied": {
3264
+ "type_scale": type_scale_choice,
3265
+ "spacing": spacing_choice,
3266
+ "color_ramps": apply_ramps,
3267
+ },
3268
+ },
3269
+ "fonts": {},
3270
+ "colors": {},
3271
+ "typography": {}, # FLAT: font.display.xl.desktop, font.display.xl.mobile
3272
+ "spacing": {}, # FLAT: space.1.desktop, space.1.mobile
3273
+ "radius": {},
3274
+ "shadows": {},
3275
+ }
3276
+
3277
+ # =========================================================================
3278
+ # FONTS
3279
+ # =========================================================================
3280
  fonts_info = get_detected_fonts()
3281
+ result["fonts"] = {
3282
+ "primary": fonts_info.get("primary", "Unknown"),
3283
+ "weights": fonts_info.get("weights", [400]),
3284
+ }
3285
  primary_font = fonts_info.get("primary", "sans-serif")
3286
 
3287
  # =========================================================================
 
3295
  state.desktop_normalized.colors, overrides, max_colors=30,
3296
  )
3297
 
3298
+ for clean_name, entry in consolidated.items():
3299
  if apply_ramps:
3300
  try:
3301
  ramp = generate_color_ramp(entry["value"])
3302
  shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"]
3303
  for i, shade in enumerate(shades):
3304
  if i < len(ramp):
3305
+ shade_key = f"{clean_name}.{shade}"
3306
+ result["colors"][shade_key] = {
3307
+ "value": ramp[i] if isinstance(ramp[i], str) else ramp[i].get("hex", entry["value"]),
3308
+ "type": "color",
3309
+ "source": "upgraded" if shade != "500" else entry.get("source", "detected"),
3310
+ }
3311
  except (ValueError, KeyError, TypeError, IndexError):
3312
+ result["colors"][clean_name] = entry
 
 
3313
  else:
3314
+ result["colors"][clean_name] = entry
 
 
3315
 
3316
  # =========================================================================
3317
  # TYPOGRAPHY - FLAT structure with viewport suffix
 
3340
  return tier
3341
  return "body"
3342
 
3343
+ # Desktop typography
3344
  if ratio:
3345
+ # Apply type scale
3346
  scales = [int(round(base_size * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
3347
  for i, token_name in enumerate(token_names):
3348
  tier = _tier_from_token(token_name)
3349
+ desktop_key = f"{token_name}.desktop"
3350
+ result["typography"][desktop_key] = {
3351
+ "value": f"{scales[i]}px",
3352
+ "type": "dimension",
3353
  "fontFamily": primary_font,
 
3354
  "fontWeight": _weight_map.get(tier, "400"),
3355
  "lineHeight": _lh_map.get(tier, "1.5"),
3356
+ "source": "upgraded",
3357
  }
 
 
 
3358
  elif state.desktop_normalized and state.desktop_normalized.typography:
3359
+ # Keep original with flat structure
3360
  for name, t in state.desktop_normalized.typography.items():
3361
  base_name = t.suggested_name or name
3362
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3363
  if not clean_name.startswith("font."):
3364
  clean_name = f"font.{clean_name}"
3365
+
3366
+ desktop_key = f"{clean_name}.desktop"
3367
+ result["typography"][desktop_key] = {
3368
+ "value": t.font_size,
3369
+ "type": "dimension",
3370
  "fontFamily": t.font_family,
 
3371
  "fontWeight": str(t.font_weight),
3372
  "lineHeight": t.line_height or "1.5",
3373
+ "source": "detected",
3374
  }
3375
+
3376
+ # Mobile typography
 
 
 
3377
  if ratio:
3378
+ # Apply type scale with mobile factor
3379
  mobile_factor = 0.875
3380
  scales = [int(round(base_size * mobile_factor * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
3381
  for i, token_name in enumerate(token_names):
3382
  tier = _tier_from_token(token_name)
3383
+ mobile_key = f"{token_name}.mobile"
3384
+ result["typography"][mobile_key] = {
3385
+ "value": f"{scales[i]}px",
3386
+ "type": "dimension",
3387
  "fontFamily": primary_font,
 
3388
  "fontWeight": _weight_map.get(tier, "400"),
3389
  "lineHeight": _lh_map.get(tier, "1.5"),
3390
+ "source": "upgraded",
3391
  }
 
 
 
3392
  elif state.mobile_normalized and state.mobile_normalized.typography:
3393
  for name, t in state.mobile_normalized.typography.items():
3394
  base_name = t.suggested_name or name
3395
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3396
  if not clean_name.startswith("font."):
3397
  clean_name = f"font.{clean_name}"
3398
+
3399
+ mobile_key = f"{clean_name}.mobile"
3400
+ result["typography"][mobile_key] = {
3401
+ "value": t.font_size,
3402
+ "type": "dimension",
3403
  "fontFamily": t.font_family,
 
3404
  "fontWeight": str(t.font_weight),
3405
  "lineHeight": t.line_height or "1.5",
3406
+ "source": "detected",
3407
  }
 
 
 
3408
 
3409
  # =========================================================================
3410
+ # SPACING - FLAT structure with viewport suffix
3411
  # =========================================================================
3412
  spacing_token_names = [
3413
  "space.1", "space.2", "space.3", "space.4", "space.5",
3414
  "space.6", "space.8", "space.10", "space.12", "space.16"
3415
  ]
3416
+
3417
  if spacing_base:
3418
  # Generate grid-aligned spacing for both viewports
3419
  for i, token_name in enumerate(spacing_token_names):
3420
  value = spacing_base * (i + 1)
3421
+
3422
  # Desktop
3423
  desktop_key = f"{token_name}.desktop"
3424
+ result["spacing"][desktop_key] = {
3425
+ "value": f"{value}px",
3426
+ "type": "dimension",
3427
+ "source": "upgraded",
3428
+ }
3429
+
3430
  # Mobile (same values)
3431
  mobile_key = f"{token_name}.mobile"
3432
+ result["spacing"][mobile_key] = {
3433
+ "value": f"{value}px",
3434
+ "type": "dimension",
3435
+ "source": "upgraded",
3436
+ }
3437
  else:
3438
+ # Keep original with flat structure
3439
  if state.desktop_normalized and state.desktop_normalized.spacing:
3440
  for name, s in state.desktop_normalized.spacing.items():
3441
  base_name = s.suggested_name or name
3442
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3443
  if not clean_name.startswith("space."):
3444
  clean_name = f"space.{clean_name}"
3445
+
3446
  desktop_key = f"{clean_name}.desktop"
3447
+ result["spacing"][desktop_key] = {
3448
+ "value": s.value,
3449
+ "type": "dimension",
3450
+ "source": "detected",
3451
+ }
3452
+
3453
  if state.mobile_normalized and state.mobile_normalized.spacing:
3454
  for name, s in state.mobile_normalized.spacing.items():
3455
  base_name = s.suggested_name or name
3456
  clean_name = base_name.replace(" ", ".").replace("_", ".").replace("-", ".").lower()
3457
  if not clean_name.startswith("space."):
3458
  clean_name = f"space.{clean_name}"
3459
+
3460
  mobile_key = f"{clean_name}.mobile"
3461
+ result["spacing"][mobile_key] = {
3462
+ "value": s.value,
3463
+ "type": "dimension",
3464
+ "source": "detected",
3465
+ }
3466
+
3467
  # =========================================================================
3468
+ # RADIUS — Semantic names (radius.sm, radius.md, radius.lg)
3469
  # =========================================================================
3470
  if state.desktop_normalized and state.desktop_normalized.radius:
3471
  seen_radius = {}
3472
  for name, r in state.desktop_normalized.radius.items():
3473
  token_name = _get_radius_token_name(r.value, seen_radius)
3474
+ result["radius"][token_name] = {
3475
+ "value": r.value,
3476
+ "type": "dimension",
3477
+ "source": "detected",
3478
+ }
3479
 
3480
  # =========================================================================
3481
+ # SHADOWS — Semantic names (shadow.xs, shadow.sm, shadow.md)
3482
  # =========================================================================
3483
  if state.desktop_normalized and state.desktop_normalized.shadows:
3484
  shadow_names = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl", "shadow.2xl"]
 
3488
  )
3489
  for i, (name, s) in enumerate(sorted_shadows):
3490
  token_name = shadow_names[i] if i < len(shadow_names) else f"shadow.{i + 1}"
3491
+ result["shadows"][token_name] = {
3492
+ "value": s.value,
3493
+ "type": "boxShadow",
3494
+ "source": "detected",
3495
+ }
3496
 
3497
  json_str = json.dumps(result, indent=2, default=str)
3498
+ token_count = sum(len(v) for k, v in result.items() if isinstance(v, dict) and k != "metadata")
3499
  upgrades_note = " (with upgrades)" if upgrades else " (no upgrades applied)"
3500
+ gr.Info(f"Final export: {token_count} tokens across {len(result) - 1} categories{upgrades_note}")
3501
  return json_str
3502
 
3503
 
core/rule_engine.py CHANGED
@@ -404,11 +404,7 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
404
  sizes_px = sorted(set(sizes))
405
 
406
  if len(sizes_px) < 2:
407
- # Use the size if valid (>= 10px), otherwise default to 16px
408
- if sizes_px and sizes_px[0] >= 10:
409
- base_size = sizes_px[0]
410
- else:
411
- base_size = 16.0
412
  return TypeScaleAnalysis(
413
  detected_ratio=1.0,
414
  closest_standard_ratio=1.25,
@@ -432,17 +428,8 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
432
 
433
  if not ratios:
434
  # Detect base size even if no valid ratios
435
- # Filter out tiny sizes (< 10px) which are likely captions/icons
436
- valid_body_sizes = [s for s in sizes_px if s >= 10]
437
- base_candidates = [s for s in valid_body_sizes if 14 <= s <= 18]
438
- if base_candidates:
439
- base_size = min(base_candidates, key=lambda x: abs(x - 16))
440
- elif valid_body_sizes:
441
- base_size = min(valid_body_sizes, key=lambda x: abs(x - 16))
442
- elif sizes_px:
443
- base_size = max(sizes_px) # Last resort: largest of tiny sizes
444
- else:
445
- base_size = 16.0
446
  return TypeScaleAnalysis(
447
  detected_ratio=1.0,
448
  closest_standard_ratio=1.25,
@@ -469,23 +456,16 @@ def analyze_type_scale(typography_tokens: dict) -> TypeScaleAnalysis:
469
 
470
  # Detect base size (closest to 16px, or 14-18px range typical for body)
471
  # The base size is typically the most common body text size
472
- # IMPORTANT: Filter out tiny sizes (< 10px) which are likely captions/icons
473
- valid_body_sizes = [s for s in sizes_px if s >= 10]
474
-
475
- base_candidates = [s for s in valid_body_sizes if 14 <= s <= 18]
476
  if base_candidates:
477
  # Prefer 16px if present, otherwise closest to 16
478
  if 16 in base_candidates:
479
  base_size = 16.0
480
  else:
481
  base_size = min(base_candidates, key=lambda x: abs(x - 16))
482
- elif valid_body_sizes:
483
- # Fallback: find size closest to 16px from valid sizes (>= 10px)
484
- # This avoids picking tiny caption/icon sizes like 7px
485
- base_size = min(valid_body_sizes, key=lambda x: abs(x - 16))
486
  elif sizes_px:
487
- # Last resort: just use the largest size if all are tiny
488
- base_size = max(sizes_px)
489
  else:
490
  base_size = 16.0
491
 
 
404
  sizes_px = sorted(set(sizes))
405
 
406
  if len(sizes_px) < 2:
407
+ base_size = sizes_px[0] if sizes_px else 16.0
 
 
 
 
408
  return TypeScaleAnalysis(
409
  detected_ratio=1.0,
410
  closest_standard_ratio=1.25,
 
428
 
429
  if not ratios:
430
  # Detect base size even if no valid ratios
431
+ base_candidates = [s for s in sizes_px if 14 <= s <= 18]
432
+ base_size = min(base_candidates, key=lambda x: abs(x - 16)) if base_candidates else (min(sizes_px, key=lambda x: abs(x - 16)) if sizes_px else 16.0)
 
 
 
 
 
 
 
 
 
433
  return TypeScaleAnalysis(
434
  detected_ratio=1.0,
435
  closest_standard_ratio=1.25,
 
456
 
457
  # Detect base size (closest to 16px, or 14-18px range typical for body)
458
  # The base size is typically the most common body text size
459
+ base_candidates = [s for s in sizes_px if 14 <= s <= 18]
 
 
 
460
  if base_candidates:
461
  # Prefer 16px if present, otherwise closest to 16
462
  if 16 in base_candidates:
463
  base_size = 16.0
464
  else:
465
  base_size = min(base_candidates, key=lambda x: abs(x - 16))
 
 
 
 
466
  elif sizes_px:
467
+ # Fallback: find size closest to 16px
468
+ base_size = min(sizes_px, key=lambda x: abs(x - 16))
469
  else:
470
  base_size = 16.0
471