riazmo Claude Opus 4.6 commited on
Commit
fb452ae
Β·
1 Parent(s): ed82870

fix: 6 critical/high bugs -- nesting, Figma crash, shade mapping, neutrals

Browse files

Figma 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 CHANGED
@@ -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, append px value
 
2851
  if seen_names is not None:
2852
  if base_name in seen_names:
2853
- return f"{base_name}.{int(px)}"
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
- current = current[part]
 
 
 
 
 
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
- # Convert "radius.md" to nested: radius.md (keep as "radius" for consistency)
 
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
- # DTCG uses "dimension" for radii, not "borderRadius"
 
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
core/color_classifier.py CHANGED
@@ -46,7 +46,7 @@ ROLE_SHADE_NAMES = {
46
  "brand": ["primary", "secondary", "accent"],
47
  "text": ["primary", "secondary", "muted"],
48
  "bg": ["primary", "secondary", "tertiary"],
49
- "border": ["light", "DEFAULT", "dark"],
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 (should be rare for non-palette)
 
585
  if token_name in used_names:
586
- base_name = token_name
587
- suffix = 2
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 unique shade per color.
630
 
631
- For N colors in a hue family, picks N evenly-spaced shade slots
632
- sorted by lightness (lightest color β†’ lightest shade).
633
- No .2/.3 suffixes ever.
 
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
- n = len(hue_colors)
647
-
648
- # Sort by luminance: lightest first β†’ gets lightest shade slot
649
  hue_colors.sort(key=lambda x: -x["luminance"])
650
 
651
- # Pick N evenly-spaced shade slots from the 10 available
652
- if n == 1:
653
- slots = ["500"]
654
- elif n == 2:
655
- slots = ["300", "700"]
656
- elif n == 3:
657
- slots = ["200", "500", "800"]
658
- elif n == 4:
659
- slots = ["100", "400", "600", "900"]
660
- else:
661
- # For n > 4 (shouldn't happen with cap=4, but safety)
662
- step = max(1, len(_SHADE_SLOTS) // n)
663
- slots = _SHADE_SLOTS[::step][:n]
664
-
665
- for idx, c in enumerate(hue_colors):
666
- role = slots[idx] if idx < len(slots) else str((idx + 1) * 100)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}"
core/color_utils.py CHANGED
@@ -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
- if s < 10 or l < 5 or l > 95:
 
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
output_json/figma-plugin-extracted/figma-design-token-creator 5/src/code.js CHANGED
@@ -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
- await figma.loadFontAsync({ family: 'Inter', style: 'Bold' });
797
- await figma.loadFontAsync({ family: 'Inter', style: 'SemiBold' });
 
 
 
 
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: 'Bold' };
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: 'Bold' };
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: 'Bold' };
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: 'Bold' };
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: 'Bold' };
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;