FirstCheck
#1
by
riazmo - opened
- agents/llm_agents.py +9 -38
- app.py +205 -369
- 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:
|
| 692 |
-
- No Near-Duplicates:
|
| 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
|
| 395 |
-
|
| 396 |
-
|
| 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 |
-
|
| 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 |
-
(
|
| 2928 |
-
(
|
| 2929 |
-
(
|
| 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 <
|
| 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 |
-
#
|
| 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
|
| 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 |
-
#
|
| 3290 |
-
result = {
|
| 3291 |
-
|
| 3292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3293 |
# =========================================================================
|
| 3294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3295 |
# =========================================================================
|
| 3296 |
if state.desktop_normalized and state.desktop_normalized.colors:
|
| 3297 |
overrides = _get_semantic_color_overrides()
|
| 3298 |
-
|
| 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
|
| 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 |
-
|
| 3321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 3340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3347 |
-
_flat_key_to_nested(flat_key, dtcg_token, result)
|
| 3348 |
-
token_count += 1
|
| 3349 |
-
|
| 3350 |
# =========================================================================
|
| 3351 |
-
# SPACING
|
| 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 |
-
|
| 3362 |
-
|
| 3363 |
-
|
| 3364 |
-
|
| 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 |
-
|
| 3375 |
-
|
| 3376 |
-
|
| 3377 |
-
|
| 3378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3379 |
# =========================================================================
|
| 3380 |
-
#
|
| 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 |
-
|
| 3387 |
-
|
| 3388 |
-
|
| 3389 |
-
|
| 3390 |
-
|
| 3391 |
|
| 3392 |
# =========================================================================
|
| 3393 |
-
# SHADOWS —
|
| 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 |
-
|
| 3403 |
-
|
| 3404 |
-
|
| 3405 |
-
|
| 3406 |
-
|
| 3407 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
#
|
| 3451 |
-
result = {
|
| 3452 |
-
|
| 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
|
| 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"{
|
| 3476 |
-
|
| 3477 |
-
|
| 3478 |
-
|
| 3479 |
-
|
|
|
|
| 3480 |
except (ValueError, KeyError, TypeError, IndexError):
|
| 3481 |
-
|
| 3482 |
-
_flat_key_to_nested(flat_key, dtcg_token, result)
|
| 3483 |
-
token_count += 1
|
| 3484 |
else:
|
| 3485 |
-
|
| 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
|
| 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 |
-
|
| 3522 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 3539 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 3546 |
-
|
| 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 |
-
|
| 3556 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 3573 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 3599 |
-
|
| 3600 |
-
|
| 3601 |
-
|
|
|
|
|
|
|
| 3602 |
# Mobile (same values)
|
| 3603 |
mobile_key = f"{token_name}.mobile"
|
| 3604 |
-
|
| 3605 |
-
|
| 3606 |
-
|
|
|
|
|
|
|
| 3607 |
else:
|
| 3608 |
-
# Keep original with
|
| 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 |
-
|
| 3618 |
-
|
| 3619 |
-
|
| 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 |
-
|
| 3630 |
-
|
| 3631 |
-
|
| 3632 |
-
|
|
|
|
|
|
|
| 3633 |
# =========================================================================
|
| 3634 |
-
#
|
| 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 |
-
|
| 3641 |
-
|
| 3642 |
-
|
| 3643 |
-
|
|
|
|
| 3644 |
|
| 3645 |
# =========================================================================
|
| 3646 |
-
# SHADOWS —
|
| 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 |
-
|
| 3657 |
-
|
| 3658 |
-
|
| 3659 |
-
|
| 3660 |
-
|
| 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 |
-
|
| 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 |
-
|
| 436 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 488 |
-
base_size =
|
| 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 |
|