Spaces:
Runtime error
fix: 6 critical/high bugs -- nesting, Figma crash, shade mapping, neutrals
Browse filesFigma Plugin (code.js):
- Remove unused Inter SemiBold pre-load that crashed Create Visual Spec
- Wrap Bold font load in try/catch with fallback to Regular
- Use dynamic headingStyle variable for all section titles
Nesting Bug (app.py + color_classifier.py):
- _flat_key_to_nested: guard against navigating into DTCG leaf tokens
- Radius collision: return None for duplicates instead of .{px} suffix
- Color collision: skip duplicates instead of appending .N suffix
Palette Shade Assignment (color_classifier.py):
- Replace fixed-slot mapping with lightness-aware assignment
- Each color luminance maps to nearest shade (50-900) via interpolation
- Collision resolution nudges to nearest free shade slot
Neutral Detection (color_utils.py):
- Raise saturation threshold from 10% to 20%
Border Naming (color_classifier.py):
- Change border names from light/DEFAULT/dark to primary/secondary/muted
Type Scale Floor (app.py):
- Add MIN_FONT_SIZE 10px for desktop and mobile
- Prevents caption/overline from generating 6-8px unreadable sizes
All 113 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- app.py +21 -7
- core/color_classifier.py +45 -30
- core/color_utils.py +2 -1
- output_json/figma-plugin-extracted/figma-design-token-creator 5/src/code.js +13 -8
|
@@ -2847,10 +2847,11 @@ def _get_radius_token_name(value_str, seen_names: dict = None) -> str:
|
|
| 2847 |
base_name = name
|
| 2848 |
break
|
| 2849 |
|
| 2850 |
-
# Handle duplicates: if two radii map to same name,
|
|
|
|
| 2851 |
if seen_names is not None:
|
| 2852 |
if base_name in seen_names:
|
| 2853 |
-
return
|
| 2854 |
seen_names[base_name] = True
|
| 2855 |
return base_name
|
| 2856 |
|
|
@@ -2911,13 +2912,22 @@ def _flat_key_to_nested(flat_key: str, value: dict, root: dict):
|
|
| 2911 |
|
| 2912 |
Example: _flat_key_to_nested('color.brand.primary', token, {})
|
| 2913 |
Result: {'color': {'brand': {'primary': token}}}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2914 |
"""
|
| 2915 |
parts = flat_key.split('.')
|
| 2916 |
current = root
|
| 2917 |
for part in parts[:-1]:
|
| 2918 |
if part not in current:
|
| 2919 |
current[part] = {}
|
| 2920 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2921 |
current[parts[-1]] = value
|
| 2922 |
|
| 2923 |
|
|
@@ -3265,7 +3275,8 @@ def export_stage1_json(convention: str = "semantic"):
|
|
| 3265 |
seen_radius = {}
|
| 3266 |
for name, r in state.desktop_normalized.radius.items():
|
| 3267 |
token_name = _get_radius_token_name(r.value, seen_radius)
|
| 3268 |
-
|
|
|
|
| 3269 |
flat_key = token_name
|
| 3270 |
dtcg_token = _to_dtcg_token(r.value, "dimension", description="Extracted from site")
|
| 3271 |
_flat_key_to_nested(flat_key, dtcg_token, result)
|
|
@@ -3399,8 +3410,10 @@ def export_tokens_json(convention: str = "semantic"):
|
|
| 3399 |
return "body"
|
| 3400 |
|
| 3401 |
# Desktop typography β W3C DTCG format
|
|
|
|
|
|
|
| 3402 |
if ratio:
|
| 3403 |
-
scales = [int(round(base_size * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
|
| 3404 |
for i, token_name in enumerate(token_names):
|
| 3405 |
tier = _tier_from_token(token_name)
|
| 3406 |
flat_key = f"{token_name}.desktop"
|
|
@@ -3434,7 +3447,7 @@ def export_tokens_json(convention: str = "semantic"):
|
|
| 3434 |
# Mobile typography β W3C DTCG format
|
| 3435 |
if ratio:
|
| 3436 |
mobile_factor = 0.875
|
| 3437 |
-
scales = [int(round(base_size * mobile_factor * (ratio ** (8-i)) / 2) * 2) for i in range(13)]
|
| 3438 |
for i, token_name in enumerate(token_names):
|
| 3439 |
tier = _tier_from_token(token_name)
|
| 3440 |
flat_key = f"{token_name}.mobile"
|
|
@@ -3522,7 +3535,8 @@ def export_tokens_json(convention: str = "semantic"):
|
|
| 3522 |
seen_radius = {}
|
| 3523 |
for name, r in state.desktop_normalized.radius.items():
|
| 3524 |
token_name = _get_radius_token_name(r.value, seen_radius)
|
| 3525 |
-
|
|
|
|
| 3526 |
dtcg_token = _to_dtcg_token(r.value, "dimension")
|
| 3527 |
_flat_key_to_nested(token_name, dtcg_token, result)
|
| 3528 |
token_count += 1
|
|
|
|
| 2847 |
base_name = name
|
| 2848 |
break
|
| 2849 |
|
| 2850 |
+
# Handle duplicates: if two radii map to same semantic name, skip the duplicate.
|
| 2851 |
+
# Old behavior appended ".{px}" which created invalid nested DTCG structures.
|
| 2852 |
if seen_names is not None:
|
| 2853 |
if base_name in seen_names:
|
| 2854 |
+
return None # Signal caller to skip this duplicate radius
|
| 2855 |
seen_names[base_name] = True
|
| 2856 |
return base_name
|
| 2857 |
|
|
|
|
| 2912 |
|
| 2913 |
Example: _flat_key_to_nested('color.brand.primary', token, {})
|
| 2914 |
Result: {'color': {'brand': {'primary': token}}}
|
| 2915 |
+
|
| 2916 |
+
Safety: If a path segment is already a DTCG leaf token ($type/$value),
|
| 2917 |
+
the new token is SKIPPED to avoid creating invalid nested structures
|
| 2918 |
+
like: {"$type":"color","$value":"#abc","2":{"$type":"color","$value":"#def"}}
|
| 2919 |
"""
|
| 2920 |
parts = flat_key.split('.')
|
| 2921 |
current = root
|
| 2922 |
for part in parts[:-1]:
|
| 2923 |
if part not in current:
|
| 2924 |
current[part] = {}
|
| 2925 |
+
node = current[part]
|
| 2926 |
+
# Guard: don't navigate into an existing leaf token
|
| 2927 |
+
if isinstance(node, dict) and ('$type' in node or '$value' in node):
|
| 2928 |
+
# This path would nest a child inside a DTCG leaf β skip silently
|
| 2929 |
+
return
|
| 2930 |
+
current = node
|
| 2931 |
current[parts[-1]] = value
|
| 2932 |
|
| 2933 |
|
|
|
|
| 3275 |
seen_radius = {}
|
| 3276 |
for name, r in state.desktop_normalized.radius.items():
|
| 3277 |
token_name = _get_radius_token_name(r.value, seen_radius)
|
| 3278 |
+
if token_name is None:
|
| 3279 |
+
continue # Duplicate radius β skip
|
| 3280 |
flat_key = token_name
|
| 3281 |
dtcg_token = _to_dtcg_token(r.value, "dimension", description="Extracted from site")
|
| 3282 |
_flat_key_to_nested(flat_key, dtcg_token, result)
|
|
|
|
| 3410 |
return "body"
|
| 3411 |
|
| 3412 |
# Desktop typography β W3C DTCG format
|
| 3413 |
+
MIN_FONT_SIZE_DESKTOP = 10 # Floor: no text style below 10px
|
| 3414 |
+
MIN_FONT_SIZE_MOBILE = 10 # Floor: same for mobile
|
| 3415 |
if ratio:
|
| 3416 |
+
scales = [max(MIN_FONT_SIZE_DESKTOP, int(round(base_size * (ratio ** (8-i)) / 2) * 2)) for i in range(13)]
|
| 3417 |
for i, token_name in enumerate(token_names):
|
| 3418 |
tier = _tier_from_token(token_name)
|
| 3419 |
flat_key = f"{token_name}.desktop"
|
|
|
|
| 3447 |
# Mobile typography β W3C DTCG format
|
| 3448 |
if ratio:
|
| 3449 |
mobile_factor = 0.875
|
| 3450 |
+
scales = [max(MIN_FONT_SIZE_MOBILE, int(round(base_size * mobile_factor * (ratio ** (8-i)) / 2) * 2)) for i in range(13)]
|
| 3451 |
for i, token_name in enumerate(token_names):
|
| 3452 |
tier = _tier_from_token(token_name)
|
| 3453 |
flat_key = f"{token_name}.mobile"
|
|
|
|
| 3535 |
seen_radius = {}
|
| 3536 |
for name, r in state.desktop_normalized.radius.items():
|
| 3537 |
token_name = _get_radius_token_name(r.value, seen_radius)
|
| 3538 |
+
if token_name is None:
|
| 3539 |
+
continue # Duplicate radius β skip
|
| 3540 |
dtcg_token = _to_dtcg_token(r.value, "dimension")
|
| 3541 |
_flat_key_to_nested(token_name, dtcg_token, result)
|
| 3542 |
token_count += 1
|
|
@@ -46,7 +46,7 @@ ROLE_SHADE_NAMES = {
|
|
| 46 |
"brand": ["primary", "secondary", "accent"],
|
| 47 |
"text": ["primary", "secondary", "muted"],
|
| 48 |
"bg": ["primary", "secondary", "tertiary"],
|
| 49 |
-
"border": ["
|
| 50 |
"feedback": ["error", "warning", "success", "info"],
|
| 51 |
}
|
| 52 |
|
|
@@ -581,13 +581,11 @@ def _assign_names(colors: list[dict], convention: str, log) -> list[ClassifiedCo
|
|
| 581 |
else:
|
| 582 |
token_name = f"{prefix}{cat}{sep}{role}"
|
| 583 |
|
| 584 |
-
# Collision guard
|
|
|
|
| 585 |
if token_name in used_names:
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
while token_name in used_names:
|
| 589 |
-
token_name = f"{base_name}{sep}{suffix}"
|
| 590 |
-
suffix += 1
|
| 591 |
used_names.add(token_name)
|
| 592 |
|
| 593 |
evidence = _build_evidence(c)
|
|
@@ -626,11 +624,12 @@ def _assign_palette_names(
|
|
| 626 |
log,
|
| 627 |
) -> list[ClassifiedColor]:
|
| 628 |
"""
|
| 629 |
-
Assign palette names by hue family with
|
| 630 |
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
|
|
|
| 634 |
"""
|
| 635 |
# Group by hue family
|
| 636 |
by_hue = {}
|
|
@@ -643,27 +642,43 @@ def _assign_palette_names(
|
|
| 643 |
result = []
|
| 644 |
|
| 645 |
for hue_fam, hue_colors in sorted(by_hue.items()):
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
# Sort by luminance: lightest first β gets lightest shade slot
|
| 649 |
hue_colors.sort(key=lambda x: -x["luminance"])
|
| 650 |
|
| 651 |
-
#
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
|
| 668 |
if convention == "tailwind":
|
| 669 |
token_name = f"{hue_fam}{sep}{role}"
|
|
|
|
| 46 |
"brand": ["primary", "secondary", "accent"],
|
| 47 |
"text": ["primary", "secondary", "muted"],
|
| 48 |
"bg": ["primary", "secondary", "tertiary"],
|
| 49 |
+
"border": ["primary", "secondary", "muted"],
|
| 50 |
"feedback": ["error", "warning", "success", "info"],
|
| 51 |
}
|
| 52 |
|
|
|
|
| 581 |
else:
|
| 582 |
token_name = f"{prefix}{cat}{sep}{role}"
|
| 583 |
|
| 584 |
+
# Collision guard β skip duplicates instead of appending .N suffix
|
| 585 |
+
# (the .N suffix creates invalid nested DTCG when _flat_key_to_nested splits by ".")
|
| 586 |
if token_name in used_names:
|
| 587 |
+
log(f"[SKIP] {c['hex']} β {token_name} collision, already taken β dropping")
|
| 588 |
+
continue
|
|
|
|
|
|
|
|
|
|
| 589 |
used_names.add(token_name)
|
| 590 |
|
| 591 |
evidence = _build_evidence(c)
|
|
|
|
| 624 |
log,
|
| 625 |
) -> list[ClassifiedColor]:
|
| 626 |
"""
|
| 627 |
+
Assign palette names by hue family with lightness-aware shade mapping.
|
| 628 |
|
| 629 |
+
Each color gets a shade (50-900) based on its ACTUAL luminance, not a
|
| 630 |
+
fixed slot. Lightest colors β lowest shade numbers (50/100), darkest β 800/900.
|
| 631 |
+
If two colors in the same hue map to the same shade, the less-frequent one
|
| 632 |
+
is nudged to the nearest free shade. No .2/.3 suffixes ever.
|
| 633 |
"""
|
| 634 |
# Group by hue family
|
| 635 |
by_hue = {}
|
|
|
|
| 642 |
result = []
|
| 643 |
|
| 644 |
for hue_fam, hue_colors in sorted(by_hue.items()):
|
| 645 |
+
# Sort by luminance: lightest first
|
|
|
|
|
|
|
| 646 |
hue_colors.sort(key=lambda x: -x["luminance"])
|
| 647 |
|
| 648 |
+
# Map each color's luminance to the nearest shade slot.
|
| 649 |
+
# Luminance 0.95+ β 50, 0.0 β 900. Linear interpolation.
|
| 650 |
+
def _luminance_to_shade(lum: float) -> str:
|
| 651 |
+
# Clamp luminance to [0, 1]
|
| 652 |
+
lum = max(0.0, min(1.0, lum))
|
| 653 |
+
# Map: high luminance β low shade, low luminance β high shade
|
| 654 |
+
# 50 β 0.95+, 100 β ~0.85, 200 β ~0.72, ..., 900 β 0.0-0.05
|
| 655 |
+
shade_idx = int((1.0 - lum) * (len(_SHADE_SLOTS) - 1) + 0.5)
|
| 656 |
+
shade_idx = max(0, min(len(_SHADE_SLOTS) - 1, shade_idx))
|
| 657 |
+
return _SHADE_SLOTS[shade_idx]
|
| 658 |
+
|
| 659 |
+
# Assign shades, resolving collisions within the same hue family
|
| 660 |
+
used_shades_in_hue = set()
|
| 661 |
+
assignments = [] # list of (color_dict, shade_str)
|
| 662 |
+
|
| 663 |
+
for c in hue_colors:
|
| 664 |
+
shade = _luminance_to_shade(c["luminance"])
|
| 665 |
+
if shade in used_shades_in_hue:
|
| 666 |
+
# Nudge to nearest free shade
|
| 667 |
+
shade_int = _SHADE_SLOTS.index(shade)
|
| 668 |
+
found = False
|
| 669 |
+
for offset in range(1, len(_SHADE_SLOTS)):
|
| 670 |
+
for direction in (+1, -1):
|
| 671 |
+
cand = shade_int + offset * direction
|
| 672 |
+
if 0 <= cand < len(_SHADE_SLOTS) and _SHADE_SLOTS[cand] not in used_shades_in_hue:
|
| 673 |
+
shade = _SHADE_SLOTS[cand]
|
| 674 |
+
found = True
|
| 675 |
+
break
|
| 676 |
+
if found:
|
| 677 |
+
break
|
| 678 |
+
used_shades_in_hue.add(shade)
|
| 679 |
+
assignments.append((c, shade))
|
| 680 |
+
|
| 681 |
+
for c, role in assignments:
|
| 682 |
|
| 683 |
if convention == "tailwind":
|
| 684 |
token_name = f"{hue_fam}{sep}{role}"
|
|
@@ -340,7 +340,8 @@ def categorize_color(color: str) -> str:
|
|
| 340 |
h, s, l = parsed.hsl
|
| 341 |
|
| 342 |
# Neutrals (low saturation or extreme lightness)
|
| 343 |
-
|
|
|
|
| 344 |
return "neutral"
|
| 345 |
|
| 346 |
# Categorize by hue
|
|
|
|
| 340 |
h, s, l = parsed.hsl
|
| 341 |
|
| 342 |
# Neutrals (low saturation or extreme lightness)
|
| 343 |
+
# s < 20 catches desaturated grays with a slight tint (e.g., #6f7597 S=16%)
|
| 344 |
+
if s < 20 or l < 5 or l > 95:
|
| 345 |
return "neutral"
|
| 346 |
|
| 347 |
# Categorize by hue
|
|
@@ -791,17 +791,22 @@ figma.ui.onmessage = async function(msg) {
|
|
| 791 |
var sectionGap = 80;
|
| 792 |
var itemGap = 16;
|
| 793 |
|
| 794 |
-
// Load Inter font for labels
|
|
|
|
| 795 |
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
|
| 796 |
-
|
| 797 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 798 |
|
| 799 |
// === COLORS SECTION ===
|
| 800 |
if (tokens.colors.length > 0) {
|
| 801 |
// Section title
|
| 802 |
var colorTitle = figma.createText();
|
| 803 |
colorTitle.characters = 'COLORS';
|
| 804 |
-
colorTitle.fontName = { family: 'Inter', style:
|
| 805 |
colorTitle.fontSize = 24;
|
| 806 |
colorTitle.x = xOffset;
|
| 807 |
colorTitle.y = yOffset;
|
|
@@ -860,7 +865,7 @@ figma.ui.onmessage = async function(msg) {
|
|
| 860 |
if (tokens.typography.length > 0) {
|
| 861 |
var typoTitle = figma.createText();
|
| 862 |
typoTitle.characters = 'TYPOGRAPHY';
|
| 863 |
-
typoTitle.fontName = { family: 'Inter', style:
|
| 864 |
typoTitle.fontSize = 24;
|
| 865 |
typoTitle.x = xOffset;
|
| 866 |
typoTitle.y = yOffset;
|
|
@@ -916,7 +921,7 @@ figma.ui.onmessage = async function(msg) {
|
|
| 916 |
if (tokens.spacing.length > 0) {
|
| 917 |
var spacingTitle = figma.createText();
|
| 918 |
spacingTitle.characters = 'SPACING';
|
| 919 |
-
spacingTitle.fontName = { family: 'Inter', style:
|
| 920 |
spacingTitle.fontSize = 24;
|
| 921 |
spacingTitle.x = xOffset;
|
| 922 |
spacingTitle.y = yOffset;
|
|
@@ -956,7 +961,7 @@ figma.ui.onmessage = async function(msg) {
|
|
| 956 |
if (tokens.borderRadius.length > 0) {
|
| 957 |
var radiusTitle = figma.createText();
|
| 958 |
radiusTitle.characters = 'BORDER RADIUS';
|
| 959 |
-
radiusTitle.fontName = { family: 'Inter', style:
|
| 960 |
radiusTitle.fontSize = 24;
|
| 961 |
radiusTitle.x = xOffset;
|
| 962 |
radiusTitle.y = yOffset;
|
|
@@ -999,7 +1004,7 @@ figma.ui.onmessage = async function(msg) {
|
|
| 999 |
if (tokens.shadows.length > 0) {
|
| 1000 |
var shadowTitle = figma.createText();
|
| 1001 |
shadowTitle.characters = 'SHADOWS';
|
| 1002 |
-
shadowTitle.fontName = { family: 'Inter', style:
|
| 1003 |
shadowTitle.fontSize = 24;
|
| 1004 |
shadowTitle.x = xOffset;
|
| 1005 |
shadowTitle.y = yOffset;
|
|
|
|
| 791 |
var sectionGap = 80;
|
| 792 |
var itemGap = 16;
|
| 793 |
|
| 794 |
+
// Load Inter font for labels (with fallbacks for environments missing styles)
|
| 795 |
+
var headingStyle = 'Regular';
|
| 796 |
await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
|
| 797 |
+
try {
|
| 798 |
+
await figma.loadFontAsync({ family: 'Inter', style: 'Bold' });
|
| 799 |
+
headingStyle = 'Bold';
|
| 800 |
+
} catch (e) {
|
| 801 |
+
console.warn('Inter Bold not available, using Regular for headings');
|
| 802 |
+
}
|
| 803 |
|
| 804 |
// === COLORS SECTION ===
|
| 805 |
if (tokens.colors.length > 0) {
|
| 806 |
// Section title
|
| 807 |
var colorTitle = figma.createText();
|
| 808 |
colorTitle.characters = 'COLORS';
|
| 809 |
+
colorTitle.fontName = { family: 'Inter', style: headingStyle };
|
| 810 |
colorTitle.fontSize = 24;
|
| 811 |
colorTitle.x = xOffset;
|
| 812 |
colorTitle.y = yOffset;
|
|
|
|
| 865 |
if (tokens.typography.length > 0) {
|
| 866 |
var typoTitle = figma.createText();
|
| 867 |
typoTitle.characters = 'TYPOGRAPHY';
|
| 868 |
+
typoTitle.fontName = { family: 'Inter', style: headingStyle };
|
| 869 |
typoTitle.fontSize = 24;
|
| 870 |
typoTitle.x = xOffset;
|
| 871 |
typoTitle.y = yOffset;
|
|
|
|
| 921 |
if (tokens.spacing.length > 0) {
|
| 922 |
var spacingTitle = figma.createText();
|
| 923 |
spacingTitle.characters = 'SPACING';
|
| 924 |
+
spacingTitle.fontName = { family: 'Inter', style: headingStyle };
|
| 925 |
spacingTitle.fontSize = 24;
|
| 926 |
spacingTitle.x = xOffset;
|
| 927 |
spacingTitle.y = yOffset;
|
|
|
|
| 961 |
if (tokens.borderRadius.length > 0) {
|
| 962 |
var radiusTitle = figma.createText();
|
| 963 |
radiusTitle.characters = 'BORDER RADIUS';
|
| 964 |
+
radiusTitle.fontName = { family: 'Inter', style: headingStyle };
|
| 965 |
radiusTitle.fontSize = 24;
|
| 966 |
radiusTitle.x = xOffset;
|
| 967 |
radiusTitle.y = yOffset;
|
|
|
|
| 1004 |
if (tokens.shadows.length > 0) {
|
| 1005 |
var shadowTitle = figma.createText();
|
| 1006 |
shadowTitle.characters = 'SHADOWS';
|
| 1007 |
+
shadowTitle.fontName = { family: 'Inter', style: headingStyle };
|
| 1008 |
shadowTitle.fontSize = 24;
|
| 1009 |
shadowTitle.x = xOffset;
|
| 1010 |
shadowTitle.y = yOffset;
|