riazmo commited on
Commit
5f6de21
Β·
verified Β·
1 Parent(s): a796b49

Upload folder using huggingface_hub

Browse files
CLAUDE.md CHANGED
@@ -1,11 +1,77 @@
1
- # Design System Extractor v3.1 β€” Project Context
2
 
3
  ## Overview
4
 
5
  A multi-agent system that extracts, analyzes, and recommends improvements for design systems from websites. The system operates in two stages:
6
 
7
- 1. **Stage 1 (Deterministic)**: Extract CSS values β†’ Normalize β†’ Rule Engine analysis β†’ **Rule-Based Color Classification** (free, no LLM)
8
- 2. **Stage 2 (LLM-powered, advisory only)**: Brand insights β†’ Benchmark comparison β†’ Best practices β†’ Final synthesis
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  ---
11
 
@@ -30,10 +96,11 @@ CSS Evidence β†’ Category:
30
  everything else β†’ PALETTE (named by hue.shade)
31
  ```
32
 
33
- ### What AURORA Does Now (Advisory Only)
34
- - Does NOT output naming_map
35
  - Provides brand insights, palette strategy, cohesion score
36
- - LLM reasoning is shown in logs but doesn't affect token names
 
 
37
 
38
  ### Files Changed in v3.1
39
  - `core/color_classifier.py` β€” NEW: Rule-based classifier with dedup, caps, naming conventions
@@ -43,6 +110,24 @@ CSS Evidence β†’ Category:
43
 
44
  ---
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  ## PREVIOUS STATUS (v3.0 and earlier): BROKEN β€” RETHINK COMPLETED
47
 
48
  ### What's Wrong (observed from real site tests)
@@ -1008,54 +1093,77 @@ NormalizedTokens:
1008
 
1009
  ---
1010
 
1011
- ## REVISED EXECUTION ORDER (Stage 1 fixes interleaved, not deferred)
1012
-
1013
- The original plan was "fix Stage 2 first, Stage 1 later." But the audit reveals:
1014
- **If normalizer sends word-based shade names to AURORA, AURORA's ReAct naming will STILL conflict with normalizer names in the export merge.**
1015
-
1016
- The pre-processing layer (Step 2 in the old plan) was supposed to fix this. But that's a bandaid β€” it re-normalizes what the normalizer already normalized. It's cleaner to fix the normalizer itself so it produces correct output from the start.
1017
 
1018
- ### New Execution Order:
1019
 
1020
  ```
1021
- PHASE 1: FIX NORMALIZER (makes Stage 1 output clean)
1022
- 1a. Unify color naming β†’ numeric shades only
1023
- 1b. Add radius normalization (parse, deduplicate, sort, name)
1024
- 1c. Add shadow normalization (parse, sort by blur, name)
1025
- 1d. Feed semantic_analyzer role hints into normalizer
1026
-
1027
- PHASE 2: FIX STAGE 2 (agents can now trust their input)
1028
- 2a. Consolidate two Stage 2 systems into one
1029
- 2b. Rewrite AURORA with ReAct + critic (names ALL colors, not 10)
1030
- 2c. Rewrite SENTINEL with grounded scoring + critic
1031
- 2d. Rewrite NEXUS with ToT
1032
- 2e. Add post-validation layer
1033
-
1034
- PHASE 3: FIX EXPORT (single naming authority)
1035
- 3a. AURORA naming_map is THE authority (not 3-way merge)
1036
- 3b. Radius/shadow export uses normalizer output directly
1037
- 3c. Validation before JSON write
1038
-
1039
- PHASE 4: FIX EXTRACTION (nice-to-have, not blocking)
1040
- 4a. Font family detection improvement
1041
- 4b. Rule engine: radius grid analysis
1042
- 4c. Rule engine: shadow elevation analysis
 
1043
  ```
1044
 
1045
- ### Why this order is better:
1046
 
1047
- 1. **Phase 1 first** because AURORA can't name colors well if the input names are garbage. The ReAct prompt says "observe your naming" but if the LLM sees `color.blue.light` in its input AND is asked to output `color.blue.300`, it gets confused.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1048
 
1049
- 2. **Phase 2 after Phase 1** because now the LLM agents receive clean, consistently-named input. AURORA's job becomes "confirm or improve these names" rather than "fix the mess from normalizer."
1050
 
1051
- 3. **Phase 3 after Phase 2** because the export layer just needs to respect one naming authority (AURORA), not reconcile three.
1052
 
1053
- 4. **Phase 4 last** because font family and enhanced rule engine analysis are improvements, not blockers.
 
 
 
 
 
 
1054
 
1055
- ### Deploy Plan:
1056
- - **Deploy 1**: After Phase 1 (normalizer fixes) β€” even without Stage 2 improvements, the export will be cleaner
1057
- - **Deploy 2**: After Phase 2 + 3 (full Stage 2 rework + export) β€” the big quality jump
1058
- - **Deploy 3**: After Phase 4 (font family, enhanced analysis) β€” polish
 
1059
 
1060
  ---
1061
 
 
1
+ # Design System Extractor v3.2 β€” Project Context
2
 
3
  ## Overview
4
 
5
  A multi-agent system that extracts, analyzes, and recommends improvements for design systems from websites. The system operates in two stages:
6
 
7
+ 1. **Stage 1 (Deterministic)**: Extract CSS values β†’ Normalize (colors, radius, shadows, typography, spacing) β†’ Rule Engine analysis β†’ **Rule-Based Color Classification** (free, no LLM)
8
+ 2. **Stage 2 (LLM-powered)**: Brand identification (AURORA) β†’ Benchmark comparison (ATLAS) β†’ Best practices (SENTINEL) β†’ Synthesis (NEXUS)
9
+ 3. **Export**: W3C DTCG v1 compliant JSON β†’ Figma Plugin (visual spec + styles/variables)
10
+
11
+ ---
12
+
13
+ ## CURRENT STATUS: v3.2 (Feb 2026)
14
+
15
+ ### What's Working
16
+
17
+ | Component | Status | Notes |
18
+ |-----------|--------|-------|
19
+ | CSS Extraction (Playwright) | βœ… Working | Desktop + mobile viewports |
20
+ | Color normalization | βœ… Working | Single numeric shade system (50-900) |
21
+ | Color classification | βœ… Working | `core/color_classifier.py` (815 lines, 100% deterministic) |
22
+ | Radius normalization | βœ… Working | Parse, deduplicate, sort, name (none/sm/md/lg/xl/2xl/full) |
23
+ | Shadow normalization | βœ… Working | Parse, sort by blur, deduplicate, name (xs/sm/md/lg/xl) |
24
+ | Typography normalization | βœ… Working | Desktop/mobile split, weight suffix |
25
+ | Spacing normalization | βœ… Working | GCD-based grid detection, base-8 alignment |
26
+ | Rule engine | βœ… Working | Type scale, WCAG AA, spacing grid, color statistics |
27
+ | LLM agents (ReAct) | βœ… Working | AURORA, ATLAS, SENTINEL, NEXUS with critic/retry |
28
+ | W3C DTCG export | βœ… Working | $value, $type, $description, $extensions |
29
+ | Figma plugin - visual spec | βœ… Working | Separate frames, AA badges, horizontal layout |
30
+ | Figma plugin - styles/variables | βœ… Working | Paint, text, effect styles + variable collections |
31
+ | Shadow interpolation | βœ… Working | Always produces 5 levels (xsβ†’xl), interpolates if fewer extracted |
32
+
33
+ ### Architecture Decisions (v3.2)
34
+
35
+ #### Naming Authority Chain (RESOLVED)
36
+ The three-naming-system conflict from v2/v3.0 is resolved:
37
+
38
+ ```
39
+ 1. Color Classifier (PRIMARY) β€” deterministic, covers ALL colors
40
+ └── Rule-based: CSS evidence β†’ category β†’ token name
41
+ └── 100% reproducible, logged with evidence
42
+
43
+ 2. AURORA LLM (SECONDARY) β€” semantic role enhancer ONLY
44
+ └── Can promote "color.blue.500" β†’ "color.brand.primary"
45
+ └── CANNOT rename palette colors
46
+ └── Only brand/text/bg/border/feedback roles accepted
47
+ └── filter_aurora_naming_map() enforces this boundary
48
+
49
+ 3. Normalizer (FALLBACK) β€” preliminary hue+shade names
50
+ └── Only used if classifier hasn't run yet
51
+ └── _generate_preliminary_name() β†’ "color.blue.500"
52
+ ```
53
+
54
+ **app.py `_get_semantic_color_overrides()`** implements this chain:
55
+ - PRIMARY: `state.color_classification.colors` (from color_classifier)
56
+ - SECONDARY: `state.brand_result.naming_map` (from AURORA, filtered to semantic roles only)
57
+
58
+ **`_generate_color_name_from_hex()`** is DEPRECATED β€” kept as thin wrapper for edge cases.
59
+
60
+ #### W3C DTCG v1 Compliance (2025.10 Spec)
61
+ - `$type` values: `color`, `dimension`, `typography`, `shadow`
62
+ - `$value` for all token values
63
+ - `$description` for human-readable descriptions
64
+ - `$extensions` with namespaced metadata: `com.design-system-extractor`
65
+ - Colors: `{frequency, confidence, category, evidence}`
66
+ - Radius: `{frequency, fitsBase4, fitsBase8}`
67
+ - Shadows: `{frequency, rawCSS, blurPx}`
68
+ - Nested structure (not flat)
69
+ - `_flat_key_to_nested()` prevents nesting inside DTCG leaf nodes
70
+
71
+ #### Deprecated Components
72
+ - `agents/semantic_analyzer.py` β€” superseded by color_classifier + normalizer._infer_role_hint()
73
+ - `agents/stage2_graph.py` β€” old LangGraph parallel system, replaced by direct async in app.py
74
+ - `app.py _generate_color_name_from_hex()` β€” third naming system, now thin wrapper
75
 
76
  ---
77
 
 
96
  everything else β†’ PALETTE (named by hue.shade)
97
  ```
98
 
99
+ ### What AURORA Does Now
 
100
  - Provides brand insights, palette strategy, cohesion score
101
+ - naming_map is filtered to semantic roles only (brand/text/bg/border/feedback)
102
+ - LLM reasoning is shown in logs
103
+ - `filter_aurora_naming_map()` in llm_agents.py enforces the boundary
104
 
105
  ### Files Changed in v3.1
106
  - `core/color_classifier.py` β€” NEW: Rule-based classifier with dedup, caps, naming conventions
 
110
 
111
  ---
112
 
113
+ ## v3.2 FIX: DTCG COMPLIANCE + NAMING AUTHORITY (Feb 2026)
114
+
115
+ ### What Changed
116
+ 1. **W3C DTCG v1 strict compliance**: `_to_dtcg_token()` now supports `$extensions` with namespaced metadata
117
+ 2. **Single naming authority resolved**: Color classifier is PRIMARY, AURORA is SECONDARY (semantic roles only)
118
+ 3. **`_get_semantic_color_overrides()` rewritten**: Uses classifier as primary, AURORA filtered to role-only names
119
+ 4. **`filter_aurora_naming_map()` added**: In llm_agents.py, strips non-semantic names from AURORA output
120
+ 5. **`_generate_color_name_from_hex()` deprecated**: Thin wrapper using `categorize_color()` from color_utils
121
+ 6. **`semantic_analyzer.py` deprecated**: Marked with deprecation notice, functionality absorbed elsewhere
122
+
123
+ ### Files Changed in v3.2
124
+ - `app.py` β€” DTCG helpers enhanced, `_get_semantic_color_overrides()` rewritten, hex-name function deprecated
125
+ - `agents/llm_agents.py` β€” Added `filter_aurora_naming_map()` function
126
+ - `agents/semantic_analyzer.py` β€” Deprecated with notice
127
+ - `CLAUDE.md` β€” Updated to current status
128
+
129
+ ---
130
+
131
  ## PREVIOUS STATUS (v3.0 and earlier): BROKEN β€” RETHINK COMPLETED
132
 
133
  ### What's Wrong (observed from real site tests)
 
1093
 
1094
  ---
1095
 
1096
+ ## EXECUTION STATUS (Updated Feb 2026)
 
 
 
 
 
1097
 
1098
+ ### Phases 1-3: COMPLETED
1099
 
1100
  ```
1101
+ PHASE 1: FIX NORMALIZER βœ… DONE
1102
+ 1a. βœ… Unify color naming β†’ numeric shades only (_generate_preliminary_name)
1103
+ 1b. βœ… Add radius normalization (parse, deduplicate, sort, name) β€” normalizer.py:626-778
1104
+ 1c. βœ… Add shadow normalization (parse, sort by blur, name) β€” normalizer.py:784-940
1105
+ 1d. βœ… Feed role hints into normalizer β€” normalizer._infer_role_hint()
1106
+
1107
+ PHASE 2: FIX STAGE 2 βœ… DONE
1108
+ 2a. βœ… Consolidated β€” llm_agents.py is primary, stage2_graph.py deprecated
1109
+ 2b. βœ… AURORA with ReAct + critic + retry β€” llm_agents.py:420-470
1110
+ 2c. βœ… SENTINEL with grounded scoring + cross-reference critic
1111
+ 2d. βœ… NEXUS with ToT (two-perspective evaluation)
1112
+ 2e. βœ… Post-validation layer β€” post_validate_stage2()
1113
+
1114
+ PHASE 3: FIX EXPORT βœ… DONE (v3.2)
1115
+ 3a. βœ… Color classifier = PRIMARY authority, AURORA = semantic roles only
1116
+ 3b. βœ… Radius/shadow export uses normalizer output directly
1117
+ 3c. βœ… W3C DTCG v1 compliance with $extensions metadata
1118
+ 3d. βœ… filter_aurora_naming_map() enforces role-only boundary
1119
+
1120
+ PHASE 4: EXTRACTION IMPROVEMENTS (NOT STARTED)
1121
+ 4a. ❌ Font family detection β€” still returns "sans-serif" fallback
1122
+ 4b. ❌ Rule engine: radius grid analysis
1123
+ 4c. ❌ Rule engine: shadow elevation analysis
1124
  ```
1125
 
1126
+ ### PHASE 5: COMPONENT GENERATION (FUTURE β€” NOT STARTED)
1127
 
1128
+ Based on strategic research (Feb 2026), the next major feature is automated component generation in Figma:
1129
+
1130
+ ```
1131
+ PHASE 5: FIGMA COMPONENT GENERATION
1132
+ 5a. Component Definition Schema (JSON defining anatomy + token bindings + variants)
1133
+ 5b. Token-to-Component binding engine
1134
+ 5c. Figma Plugin: createComponent() + combineAsVariants() + setBoundVariable()
1135
+ 5d. MVP Components: Button (60 variants), TextInput (8), Card (2), Toast (4), Checkbox+Radio (12)
1136
+ 5e. Variable Collections: Primitives, Semantic, Spacing, Radius, Typography
1137
+
1138
+ PHASE 6: ECOSYSTEM INTEGRATION
1139
+ 6a. Style Dictionary v4 compatible output (50+ platform formats for free)
1140
+ 6b. Tokens Studio compatible JSON import
1141
+ 6c. Dembrandt JSON as alternative input source
1142
+ 6d. CI/CD GitHub Action for design system regression checks
1143
+
1144
+ PHASE 7: MCP INTEGRATION
1145
+ 7a. Expose extractor as MCP tool server
1146
+ 7b. Claude Desktop: "Extract design system from example.com"
1147
+ 7c. Community Figma MCP bridge for push-to-Figma
1148
+ ```
1149
 
1150
+ ### Strategic Positioning
1151
 
1152
+ **"Lighthouse for Design Systems"** β€” We are NOT a token management platform (Tokens Studio), NOT a documentation platform (Zeroheight), NOT an extraction tool (Dembrandt). We are the **automated audit + bootstrap tool** that sits upstream of all of those.
1153
 
1154
+ **Unique differentiators no competitor has:**
1155
+ - Type scale ratio detection + standard scale matching
1156
+ - Spacing grid detection (GCD-based, base-8 alignment scoring)
1157
+ - LLM brand identification from CSS usage patterns
1158
+ - Holistic design system quality score (0-100)
1159
+ - Visual spec page auto-generated in Figma
1160
+ - Benchmark comparison against established design systems
1161
 
1162
+ **Key competitors to watch:**
1163
+ - Dembrandt (1,300β˜…) β€” does extraction better, but no analysis
1164
+ - Tokens Studio (264K users) β€” does Figma management better, but no extraction
1165
+ - Knapsack ($10M funding) β€” building ingestion engine, biggest strategic threat
1166
+ - html.to.design β€” captures layouts but not tokens/variables
1167
 
1168
  ---
1169
 
agents/llm_agents.py CHANGED
@@ -1214,6 +1214,34 @@ def _apply_sentinel_fixes(result: BestPracticesResult, rule_engine, errors: list
1214
  return result
1215
 
1216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1217
  def post_validate_stage2(
1218
  aurora: BrandIdentification,
1219
  sentinel: BestPracticesResult,
 
1214
  return result
1215
 
1216
 
1217
+ def filter_aurora_naming_map(aurora: BrandIdentification) -> dict:
1218
+ """Filter AURORA naming_map to only keep semantic role assignments.
1219
+
1220
+ AURORA is a secondary naming authority β€” it can assign semantic roles
1221
+ (brand.primary, text.secondary, bg.primary, feedback.error, etc.)
1222
+ but cannot override palette names (blue.500, neutral.700, etc.).
1223
+
1224
+ The color_classifier is the primary naming authority.
1225
+
1226
+ Returns:
1227
+ Dict of hex -> semantic_name (only role-based names).
1228
+ """
1229
+ SEMANTIC_PREFIXES = ('brand.', 'text.', 'bg.', 'border.', 'feedback.')
1230
+ filtered = {}
1231
+
1232
+ for hex_val, name in (aurora.naming_map or {}).items():
1233
+ hex_clean = str(hex_val).strip().lower()
1234
+ if not hex_clean.startswith('#') or not name:
1235
+ continue
1236
+ clean_name = name if name.startswith('color.') else f'color.{name}'
1237
+ # Extract the part after "color."
1238
+ after_prefix = clean_name[6:] # "brand.primary", "blue.500", etc.
1239
+ if any(after_prefix.startswith(sp) for sp in SEMANTIC_PREFIXES):
1240
+ filtered[hex_clean] = clean_name
1241
+
1242
+ return filtered
1243
+
1244
+
1245
  def post_validate_stage2(
1246
  aurora: BrandIdentification,
1247
  sentinel: BestPracticesResult,
agents/semantic_analyzer.py CHANGED
@@ -2,6 +2,15 @@
2
  Agent 1C: Semantic Color Analyzer
3
  Design System Extractor v2
4
 
 
 
 
 
 
 
 
 
 
5
  Persona: Design System Semanticist
6
 
7
  Responsibilities:
 
2
  Agent 1C: Semantic Color Analyzer
3
  Design System Extractor v2
4
 
5
+ ⚠️ DEPRECATED in v3.2 β€” Superseded by:
6
+ - core/color_classifier.py (rule-based, primary naming authority)
7
+ - agents/normalizer.py._infer_role_hint() (role hints for AURORA)
8
+ - AURORA agent in llm_agents.py (semantic role enhancement only)
9
+
10
+ This module is kept for backward compatibility but should not be called
11
+ in the main pipeline. Its heuristics have been absorbed into
12
+ normalizer._infer_role_hint() and color_classifier.classify_colors().
13
+
14
  Persona: Design System Semanticist
15
 
16
  Responsibilities:
app.py CHANGED
@@ -1502,14 +1502,12 @@ async def run_stage2_analysis_v2(
1502
  shadow_count = 0
1503
  if state.desktop_normalized:
1504
  shadow_count = len(getattr(state.desktop_normalized, 'shadows', {}))
1505
- tobe_shadow_count = min(shadow_count, 5) # Export only what was extracted (capped at 5)
1506
- _SHADOW_LABELS = {1: "md", 2: "sm β†’ lg", 3: "sm β†’ md β†’ lg", 4: "xs β†’ sm β†’ lg β†’ xl", 5: "xs β†’ sm β†’ md β†’ lg β†’ xl"}
1507
- tobe_label = _SHADOW_LABELS.get(tobe_shadow_count, f"{tobe_shadow_count} levels")
1508
  cards.append(_render_as_is_to_be(
1509
- "Shadows", f"{shadow_count} levels",
1510
  "Elevation tokens" if shadow_count > 0 else "No shadows found",
1511
  f"{tobe_shadow_count} levels",
1512
- tobe_label,
1513
  icon="🌫️"
1514
  ))
1515
  asis_tobe_html = "".join(cards)
@@ -2949,20 +2947,29 @@ def _flat_key_to_nested(flat_key: str, value: dict, root: dict):
2949
  current[parts[-1]] = value
2950
 
2951
 
2952
- def _to_dtcg_token(value, token_type: str, description: str = None, source: str = None) -> dict:
2953
- """Wrap value in W3C DTCG format with $value, $type, $description.
 
 
 
2954
 
2955
  Args:
2956
  value: The token value
2957
- token_type: W3C DTCG type (color, typography, dimension, shadow)
 
 
 
2958
  description: Optional human-readable description
2959
  source: Optional source indicator (extracted, recommended, semantic)
 
2960
  """
2961
  token = {"$type": token_type, "$value": value}
2962
- if description:
 
 
2963
  token["$description"] = description
2964
- if source:
2965
- token["$description"] = f"[{source}] {description or ''}"
2966
  return token
2967
 
2968
 
@@ -2984,21 +2991,42 @@ def _shadow_to_dtcg(shadow_dict: dict) -> dict:
2984
  def _get_semantic_color_overrides() -> dict:
2985
  """Build color hex->semantic name map.
2986
 
2987
- v3: AURORA naming_map is the SINGLE naming authority.
2988
- Falls back to normalizer suggested_name, then _generate_color_name_from_hex.
 
 
 
 
 
 
2989
  """
2990
  overrides = {} # hex -> semantic_name
2991
 
2992
- # PRIMARY: AURORA's naming_map (covers ALL colors if critic passed)
 
 
 
 
 
 
 
 
 
 
 
2993
  brand_result = getattr(state, 'brand_result', None)
2994
  if brand_result:
2995
  naming_map = getattr(brand_result, 'naming_map', None)
2996
  if isinstance(naming_map, dict) and naming_map:
2997
  for hex_val, name in naming_map.items():
2998
  hex_clean = str(hex_val).strip().lower()
2999
- if hex_clean.startswith('#') and name:
3000
- # Ensure color. prefix
3001
- clean_name = name if name.startswith('color.') else f'color.{name}'
 
 
 
 
3002
  overrides[hex_clean] = clean_name
3003
 
3004
  return overrides
@@ -3015,90 +3043,48 @@ def _is_valid_hex_color(value: str) -> bool:
3015
 
3016
 
3017
  def _generate_color_name_from_hex(hex_val: str, used_names: set = None) -> str:
3018
- """Generate a semantic color name based on the color's HSL characteristics.
3019
 
3020
- Returns names like: color.neutral.400, color.blue.500, color.red.300
3021
- Uses standard design system naming conventions.
3022
  """
 
3023
  import colorsys
3024
 
3025
  used_names = used_names or set()
3026
 
3027
- # Parse hex to RGB
3028
  hex_clean = hex_val.lstrip('#').lower()
3029
  if len(hex_clean) == 3:
3030
- hex_clean = ''.join([c*2 for c in hex_clean])
3031
 
3032
  try:
3033
  r = int(hex_clean[0:2], 16) / 255
3034
  g = int(hex_clean[2:4], 16) / 255
3035
  b = int(hex_clean[4:6], 16) / 255
3036
  except (ValueError, IndexError):
3037
- return "color.other.base"
3038
 
3039
- # Convert to HSL
3040
  h, l, s = colorsys.rgb_to_hls(r, g, b)
3041
- hue = h * 360
3042
- saturation = s
3043
- lightness = l
3044
-
3045
- # Determine color family based on hue (for saturated colors)
3046
- if saturation < 0.1:
3047
- # Grayscale / neutral
3048
- color_family = "neutral"
3049
- else:
3050
- # Map hue to color name
3051
- if hue < 15 or hue >= 345:
3052
- color_family = "red"
3053
- elif hue < 45:
3054
- color_family = "orange"
3055
- elif hue < 75:
3056
- color_family = "yellow"
3057
- elif hue < 150:
3058
- color_family = "green"
3059
- elif hue < 195:
3060
- color_family = "teal"
3061
- elif hue < 255:
3062
- color_family = "blue"
3063
- elif hue < 285:
3064
- color_family = "purple"
3065
- elif hue < 345:
3066
- color_family = "pink"
3067
- else:
3068
- color_family = "red"
3069
-
3070
- # Determine shade based on lightness (100-900 scale)
3071
- if lightness >= 0.95:
3072
- shade = "50"
3073
- elif lightness >= 0.85:
3074
- shade = "100"
3075
- elif lightness >= 0.75:
3076
- shade = "200"
3077
- elif lightness >= 0.65:
3078
- shade = "300"
3079
- elif lightness >= 0.50:
3080
- shade = "400"
3081
- elif lightness >= 0.40:
3082
- shade = "500"
3083
- elif lightness >= 0.30:
3084
- shade = "600"
3085
- elif lightness >= 0.20:
3086
- shade = "700"
3087
- elif lightness >= 0.10:
3088
- shade = "800"
3089
- else:
3090
- shade = "900"
3091
 
3092
- # Generate base name
3093
  base_name = f"color.{color_family}.{shade}"
3094
-
3095
- # Handle conflicts by adding suffix
3096
  final_name = base_name
3097
  suffix = 1
3098
  while final_name in used_names:
3099
  suffix += 1
3100
  final_name = f"{base_name}_{suffix}"
3101
-
3102
  return final_name
3103
 
3104
 
@@ -3212,7 +3198,14 @@ def export_stage1_json(convention: str = "semantic"):
3212
  log_callback=state.log,
3213
  )
3214
  for c in classification.colors:
3215
- dtcg_token = _to_dtcg_token(c.hex, "color", description=f"Rule-based: {c.category}")
 
 
 
 
 
 
 
3216
  _flat_key_to_nested(c.token_name, dtcg_token, result)
3217
  token_count += 1
3218
 
@@ -3287,7 +3280,7 @@ def export_stage1_json(convention: str = "semantic"):
3287
  token_count += 1
3288
 
3289
  # =========================================================================
3290
- # BORDER RADIUS β€” Nested structure (DTCG uses "dimension" type for radii)
3291
  # =========================================================================
3292
  if state.desktop_normalized and state.desktop_normalized.radius:
3293
  seen_radius = {}
@@ -3296,7 +3289,14 @@ def export_stage1_json(convention: str = "semantic"):
3296
  if token_name is None:
3297
  continue # Duplicate radius β€” skip
3298
  flat_key = token_name
3299
- dtcg_token = _to_dtcg_token(r.value, "dimension", description="Extracted from site")
 
 
 
 
 
 
 
3300
  _flat_key_to_nested(flat_key, dtcg_token, result)
3301
  token_count += 1
3302
 
@@ -3304,18 +3304,22 @@ def export_stage1_json(convention: str = "semantic"):
3304
  # SHADOWS β€” W3C DTCG shadow format
3305
  # =========================================================================
3306
  if state.desktop_normalized and state.desktop_normalized.shadows:
3307
- shadow_names = ["xs", "sm", "md", "lg", "xl", "2xl"]
3308
  sorted_shadows = sorted(
3309
  state.desktop_normalized.shadows.items(),
3310
  key=lambda x: _get_shadow_blur(x[1].value),
3311
  )
3312
  for i, (name, s) in enumerate(sorted_shadows):
3313
- size_name = shadow_names[i] if i < len(shadow_names) else str(i + 1)
3314
  flat_key = f"shadow.{size_name}"
3315
- # Parse CSS shadow and convert to DTCG format
3316
  parsed = _parse_shadow_to_tokens_studio(s.value)
3317
  dtcg_shadow_value = _shadow_to_dtcg(parsed)
3318
- dtcg_token = _to_dtcg_token(dtcg_shadow_value, "shadow", description="Extracted from site")
 
 
 
 
 
3319
  _flat_key_to_nested(flat_key, dtcg_token, result)
3320
  token_count += 1
3321
 
@@ -3605,16 +3609,11 @@ def export_tokens_json(convention: str = "semantic"):
3605
  token_count += 1
3606
 
3607
  # =========================================================================
3608
- # SHADOWS β€” W3C DTCG format β€” export ONLY what was actually extracted
 
3609
  # =========================================================================
3610
- # Name mapping: assign best-fit names based on how many shadows were found
3611
- _SHADOW_NAMES_BY_COUNT = {
3612
- 1: ["shadow.md"],
3613
- 2: ["shadow.sm", "shadow.lg"],
3614
- 3: ["shadow.sm", "shadow.md", "shadow.lg"],
3615
- 4: ["shadow.xs", "shadow.sm", "shadow.lg", "shadow.xl"],
3616
- 5: ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl"],
3617
- }
3618
 
3619
  if state.desktop_normalized and state.desktop_normalized.shadows:
3620
  sorted_shadows = sorted(
@@ -3634,16 +3633,54 @@ def export_tokens_json(convention: str = "semantic"):
3634
  "color": p.get("color", "rgba(0,0,0,0.25)"),
3635
  })
3636
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3637
  n = len(parsed_shadows)
3638
- # Cap at 5 maximum, take first 5 sorted by blur
3639
- final_shadows = parsed_shadows[:5]
3640
- names = _SHADOW_NAMES_BY_COUNT.get(len(final_shadows))
3641
- if names is None:
3642
- # Fallback for n > 5: xs, sm, md, lg, xl
3643
- names = [f"shadow.{i+1}" for i in range(len(final_shadows))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3644
 
3645
  for i, shadow in enumerate(final_shadows):
3646
- token_name = names[i] if i < len(names) else f"shadow.{i + 1}"
3647
  dtcg_value = {
3648
  "color": shadow["color"],
3649
  "offsetX": f"{shadow['x']}px",
 
1502
  shadow_count = 0
1503
  if state.desktop_normalized:
1504
  shadow_count = len(getattr(state.desktop_normalized, 'shadows', {}))
1505
+ tobe_shadow_count = max(shadow_count, 5) if shadow_count > 0 else 0 # Always 5 levels (interpolated)
 
 
1506
  cards.append(_render_as_is_to_be(
1507
+ "Shadows", f"{shadow_count} extracted",
1508
  "Elevation tokens" if shadow_count > 0 else "No shadows found",
1509
  f"{tobe_shadow_count} levels",
1510
+ "xs β†’ sm β†’ md β†’ lg β†’ xl" + (f" (interpolated from {shadow_count})" if shadow_count < 5 else ""),
1511
  icon="🌫️"
1512
  ))
1513
  asis_tobe_html = "".join(cards)
 
2947
  current[parts[-1]] = value
2948
 
2949
 
2950
+ def _to_dtcg_token(value, token_type: str, description: str = None,
2951
+ source: str = None, extensions: dict = None) -> dict:
2952
+ """Wrap value in W3C DTCG v1 (2025.10) format.
2953
+
2954
+ Spec: https://www.designtokens.org/tr/drafts/format/
2955
 
2956
  Args:
2957
  value: The token value
2958
+ token_type: W3C DTCG type β€” must be one of:
2959
+ color, dimension, fontFamily, fontWeight, number,
2960
+ duration, cubicBezier, shadow, strokeStyle, border,
2961
+ transition, gradient, typography
2962
  description: Optional human-readable description
2963
  source: Optional source indicator (extracted, recommended, semantic)
2964
+ extensions: Optional dict for $extensions (custom metadata like frequency, confidence)
2965
  """
2966
  token = {"$type": token_type, "$value": value}
2967
+ if description and source:
2968
+ token["$description"] = f"[{source}] {description}"
2969
+ elif description:
2970
  token["$description"] = description
2971
+ if extensions:
2972
+ token["$extensions"] = {"com.design-system-extractor": extensions}
2973
  return token
2974
 
2975
 
 
2991
  def _get_semantic_color_overrides() -> dict:
2992
  """Build color hex->semantic name map.
2993
 
2994
+ v3.2: Color classifier is the PRIMARY naming authority (deterministic, reproducible).
2995
+ AURORA is a SECONDARY enhancer β€” it can only ADD semantic role names
2996
+ (brand.primary, text.secondary, etc.) but cannot override palette names.
2997
+
2998
+ Authority chain:
2999
+ 1. Color classifier (rule-based, covers ALL colors)
3000
+ 2. AURORA naming_map (LLM, only brand/text/bg/border/feedback roles accepted)
3001
+ 3. Normalizer suggested_name (fallback)
3002
  """
3003
  overrides = {} # hex -> semantic_name
3004
 
3005
+ # PRIMARY: Color classifier (deterministic, covers ALL colors)
3006
+ classified = getattr(state, 'color_classification', None)
3007
+ if classified and hasattr(classified, 'colors'):
3008
+ for cc in classified.colors:
3009
+ hex_clean = cc.hex.strip().lower()
3010
+ if hex_clean.startswith('#') and cc.token_name:
3011
+ overrides[hex_clean] = cc.token_name
3012
+
3013
+ # SECONDARY: AURORA naming_map β€” ONLY accept semantic role upgrades
3014
+ # AURORA can promote "color.blue.500" to "color.brand.primary"
3015
+ # but cannot rename palette colors to different palette names
3016
+ _SEMANTIC_ROLES = {'brand.', 'text.', 'bg.', 'border.', 'feedback.'}
3017
  brand_result = getattr(state, 'brand_result', None)
3018
  if brand_result:
3019
  naming_map = getattr(brand_result, 'naming_map', None)
3020
  if isinstance(naming_map, dict) and naming_map:
3021
  for hex_val, name in naming_map.items():
3022
  hex_clean = str(hex_val).strip().lower()
3023
+ if not hex_clean.startswith('#') or not name:
3024
+ continue
3025
+ clean_name = name if name.startswith('color.') else f'color.{name}'
3026
+ # Only accept semantic role names from AURORA
3027
+ name_after_color = clean_name[6:] # strip "color."
3028
+ is_semantic_role = any(name_after_color.startswith(r) for r in _SEMANTIC_ROLES)
3029
+ if is_semantic_role:
3030
  overrides[hex_clean] = clean_name
3031
 
3032
  return overrides
 
3043
 
3044
 
3045
  def _generate_color_name_from_hex(hex_val: str, used_names: set = None) -> str:
3046
+ """DEPRECATED: Use normalizer._generate_preliminary_name() instead.
3047
 
3048
+ Kept as thin wrapper for backward compatibility.
3049
+ Delegates to normalizer's naming logic via color_utils.categorize_color().
3050
  """
3051
+ from core.color_utils import categorize_color, parse_color
3052
  import colorsys
3053
 
3054
  used_names = used_names or set()
3055
 
 
3056
  hex_clean = hex_val.lstrip('#').lower()
3057
  if len(hex_clean) == 3:
3058
+ hex_clean = ''.join([c * 2 for c in hex_clean])
3059
 
3060
  try:
3061
  r = int(hex_clean[0:2], 16) / 255
3062
  g = int(hex_clean[2:4], 16) / 255
3063
  b = int(hex_clean[4:6], 16) / 255
3064
  except (ValueError, IndexError):
3065
+ return "color.other.500"
3066
 
 
3067
  h, l, s = colorsys.rgb_to_hls(r, g, b)
3068
+ color_family = categorize_color(hex_val) or "neutral"
3069
+
3070
+ # Numeric shade from lightness (matches normalizer._generate_preliminary_name)
3071
+ if l >= 0.95: shade = "50"
3072
+ elif l >= 0.85: shade = "100"
3073
+ elif l >= 0.75: shade = "200"
3074
+ elif l >= 0.65: shade = "300"
3075
+ elif l >= 0.50: shade = "400"
3076
+ elif l >= 0.40: shade = "500"
3077
+ elif l >= 0.30: shade = "600"
3078
+ elif l >= 0.20: shade = "700"
3079
+ elif l >= 0.10: shade = "800"
3080
+ else: shade = "900"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3081
 
 
3082
  base_name = f"color.{color_family}.{shade}"
 
 
3083
  final_name = base_name
3084
  suffix = 1
3085
  while final_name in used_names:
3086
  suffix += 1
3087
  final_name = f"{base_name}_{suffix}"
 
3088
  return final_name
3089
 
3090
 
 
3198
  log_callback=state.log,
3199
  )
3200
  for c in classification.colors:
3201
+ ext = {"frequency": c.frequency, "confidence": c.confidence, "category": c.category}
3202
+ if c.evidence:
3203
+ ext["evidence"] = c.evidence[:3] # Top 3 evidence items
3204
+ dtcg_token = _to_dtcg_token(
3205
+ c.hex, "color",
3206
+ description=f"{c.category}: {c.role}",
3207
+ extensions=ext,
3208
+ )
3209
  _flat_key_to_nested(c.token_name, dtcg_token, result)
3210
  token_count += 1
3211
 
 
3280
  token_count += 1
3281
 
3282
  # =========================================================================
3283
+ # BORDER RADIUS β€” W3C DTCG "dimension" type
3284
  # =========================================================================
3285
  if state.desktop_normalized and state.desktop_normalized.radius:
3286
  seen_radius = {}
 
3289
  if token_name is None:
3290
  continue # Duplicate radius β€” skip
3291
  flat_key = token_name
3292
+ ext = {"frequency": r.frequency}
3293
+ if hasattr(r, 'fits_base_4') and r.fits_base_4 is not None:
3294
+ ext["fitsBase4"] = r.fits_base_4
3295
+ if hasattr(r, 'fits_base_8') and r.fits_base_8 is not None:
3296
+ ext["fitsBase8"] = r.fits_base_8
3297
+ dtcg_token = _to_dtcg_token(r.value, "dimension",
3298
+ description=f"Border radius ({name})",
3299
+ extensions=ext)
3300
  _flat_key_to_nested(flat_key, dtcg_token, result)
3301
  token_count += 1
3302
 
 
3304
  # SHADOWS β€” W3C DTCG shadow format
3305
  # =========================================================================
3306
  if state.desktop_normalized and state.desktop_normalized.shadows:
3307
+ shadow_tier_names = ["xs", "sm", "md", "lg", "xl", "2xl"]
3308
  sorted_shadows = sorted(
3309
  state.desktop_normalized.shadows.items(),
3310
  key=lambda x: _get_shadow_blur(x[1].value),
3311
  )
3312
  for i, (name, s) in enumerate(sorted_shadows):
3313
+ size_name = shadow_tier_names[i] if i < len(shadow_tier_names) else str(i + 1)
3314
  flat_key = f"shadow.{size_name}"
 
3315
  parsed = _parse_shadow_to_tokens_studio(s.value)
3316
  dtcg_shadow_value = _shadow_to_dtcg(parsed)
3317
+ ext = {"frequency": s.frequency, "rawCSS": s.value}
3318
+ if hasattr(s, 'blur_px') and s.blur_px is not None:
3319
+ ext["blurPx"] = s.blur_px
3320
+ dtcg_token = _to_dtcg_token(dtcg_shadow_value, "shadow",
3321
+ description=f"Elevation {size_name}",
3322
+ extensions=ext)
3323
  _flat_key_to_nested(flat_key, dtcg_token, result)
3324
  token_count += 1
3325
 
 
3609
  token_count += 1
3610
 
3611
  # =========================================================================
3612
+ # SHADOWS — W3C DTCG format — always produce 5 elevation levels (xs→xl)
3613
+ # Interpolates between extracted shadows to fill missing levels.
3614
  # =========================================================================
3615
+ TARGET_SHADOW_COUNT = 5
3616
+ shadow_names = ["shadow.xs", "shadow.sm", "shadow.md", "shadow.lg", "shadow.xl"]
 
 
 
 
 
 
3617
 
3618
  if state.desktop_normalized and state.desktop_normalized.shadows:
3619
  sorted_shadows = sorted(
 
3633
  "color": p.get("color", "rgba(0,0,0,0.25)"),
3634
  })
3635
 
3636
+ # Interpolation helpers
3637
+ def _lerp(a, b, t):
3638
+ return a + (b - a) * t
3639
+
3640
+ def _lerp_shadow(s1, s2, t):
3641
+ """Interpolate between two shadow dicts at factor t (0.0=s1, 1.0=s2)."""
3642
+ import re
3643
+ interp = {
3644
+ "x": round(_lerp(s1["x"], s2["x"], t), 1),
3645
+ "y": round(_lerp(s1["y"], s2["y"], t), 1),
3646
+ "blur": round(_lerp(s1["blur"], s2["blur"], t), 1),
3647
+ "spread": round(_lerp(s1["spread"], s2["spread"], t), 1),
3648
+ }
3649
+ alpha1, alpha2 = 0.25, 0.25
3650
+ m1 = re.search(r'rgba?\([^)]*,\s*([\d.]+)\)', s1["color"])
3651
+ m2 = re.search(r'rgba?\([^)]*,\s*([\d.]+)\)', s2["color"])
3652
+ if m1: alpha1 = float(m1.group(1))
3653
+ if m2: alpha2 = float(m2.group(1))
3654
+ interp_alpha = round(_lerp(alpha1, alpha2, t), 3)
3655
+ interp["color"] = f"rgba(0, 0, 0, {interp_alpha})"
3656
+ return interp
3657
+
3658
+ final_shadows = []
3659
  n = len(parsed_shadows)
3660
+ if n >= TARGET_SHADOW_COUNT:
3661
+ final_shadows = parsed_shadows[:TARGET_SHADOW_COUNT]
3662
+ elif n == 1:
3663
+ base = parsed_shadows[0]
3664
+ for i in range(TARGET_SHADOW_COUNT):
3665
+ factor = (i + 1) / 3.0
3666
+ final_shadows.append({
3667
+ "x": round(base["x"] * factor, 1),
3668
+ "y": round(max(1, base["y"] * factor), 1),
3669
+ "blur": round(max(1, base["blur"] * factor), 1),
3670
+ "spread": round(base["spread"] * factor, 1),
3671
+ "color": f"rgba(0, 0, 0, {round(0.04 + i * 0.04, 3)})",
3672
+ })
3673
+ elif n >= 2:
3674
+ for i in range(TARGET_SHADOW_COUNT):
3675
+ t = i / (TARGET_SHADOW_COUNT - 1)
3676
+ src_pos = t * (n - 1)
3677
+ lo = int(src_pos)
3678
+ hi = min(lo + 1, n - 1)
3679
+ frac = src_pos - lo
3680
+ final_shadows.append(_lerp_shadow(parsed_shadows[lo], parsed_shadows[hi], frac))
3681
 
3682
  for i, shadow in enumerate(final_shadows):
3683
+ token_name = shadow_names[i] if i < len(shadow_names) else f"shadow.{i + 1}"
3684
  dtcg_value = {
3685
  "color": shadow["color"],
3686
  "offsetX": f"{shadow['x']}px",
output_json/figma-plugin-extracted/figma-design-token-creator 5/src/code.js CHANGED
@@ -40,6 +40,31 @@ function hexToRgb(hex) {
40
  } : { r: 0, g: 0, b: 0 };
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  // Helper to convert font weight to Figma font style name
44
  function getFontStyleFromWeight(weight) {
45
  var weightStr = String(weight).toLowerCase();
@@ -782,301 +807,499 @@ figma.ui.onmessage = async function(msg) {
782
  }
783
 
784
  // ==========================================
785
- // VISUAL SPEC GENERATOR
786
- // Creates a visual reference page showing all tokens
 
787
  // ==========================================
788
  if (msg.type === 'create-visual-spec') {
789
  try {
790
  var rawTokens = msg.tokens;
791
-
792
- // Validate tokens exist
793
  if (!rawTokens) {
794
- figma.ui.postMessage({
795
- type: 'error',
796
- message: 'No tokens provided. Please load a JSON file first.'
797
- });
798
  return;
799
  }
800
 
801
- console.log('Creating visual spec with tokens:', Object.keys(rawTokens));
802
  var tokens = normalizeTokens(rawTokens);
803
- console.log('Normalized tokens - colors:', tokens.colors.length, 'typography:', tokens.typography.length);
804
-
805
- // Use current page instead of creating new page (Figma Starter plan has 3-page limit)
806
  var specPage = figma.currentPage;
807
  specPage.name = '🎨 Design System Spec';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
808
 
809
- // Clear existing children on the page so spec starts fresh
810
- while (specPage.children.length > 0) {
811
- specPage.children[0].remove();
 
 
 
 
 
 
 
 
 
 
 
 
812
  }
813
 
814
- var yOffset = 0;
815
- var xOffset = 0;
816
- var sectionGap = 80;
817
- var itemGap = 16;
818
-
819
- // Load Inter font for labels (with fallbacks for environments missing styles)
820
- var headingStyle = 'Regular';
821
- await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
822
- try {
823
- await figma.loadFontAsync({ family: 'Inter', style: 'Bold' });
824
- headingStyle = 'Bold';
825
- } catch (e) {
826
- console.warn('Inter Bold not available, using Regular for headings');
827
  }
828
 
829
- // === COLORS SECTION ===
 
 
 
 
 
 
 
 
 
 
 
 
 
830
  if (tokens.colors.length > 0) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  // Section title
832
- var colorTitle = figma.createText();
833
- colorTitle.characters = 'COLORS';
834
- colorTitle.fontName = { family: 'Inter', style: headingStyle };
835
- colorTitle.fontSize = 24;
836
- colorTitle.x = xOffset;
837
- colorTitle.y = yOffset;
838
- specPage.appendChild(colorTitle);
839
- yOffset += 50;
840
-
841
- var colorX = xOffset;
842
- var colorY = yOffset;
843
- var swatchSize = 60;
844
- var swatchGap = 16;
845
- var colsPerRow = 6;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846
 
847
- for (var ci = 0; ci < tokens.colors.length; ci++) {
848
- var colorToken = tokens.colors[ci];
849
- var col = ci % colsPerRow;
850
- var row = Math.floor(ci / colsPerRow);
851
-
852
- // Color swatch
853
- var swatch = figma.createRectangle();
854
- swatch.resize(swatchSize, swatchSize);
855
- swatch.x = colorX + col * (swatchSize + swatchGap);
856
- swatch.y = colorY + row * (swatchSize + swatchGap + 30);
857
- swatch.cornerRadius = 8;
858
-
859
- var rgb = hexToRgb(colorToken.value);
860
- swatch.fills = [{ type: 'SOLID', color: rgb }];
861
- swatch.strokes = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }];
862
- swatch.strokeWeight = 1;
863
- specPage.appendChild(swatch);
864
-
865
- // Color name label
866
- var colorLabel = figma.createText();
867
- colorLabel.characters = colorToken.name.split('/').pop() || colorToken.name;
868
- colorLabel.fontName = { family: 'Inter', style: 'Regular' };
869
- colorLabel.fontSize = 10;
870
- colorLabel.x = swatch.x;
871
- colorLabel.y = swatch.y + swatchSize + 4;
872
- specPage.appendChild(colorLabel);
873
-
874
- // Color value label
875
- var valueLabel = figma.createText();
876
- valueLabel.characters = colorToken.value.toUpperCase();
877
- valueLabel.fontName = { family: 'Inter', style: 'Regular' };
878
- valueLabel.fontSize = 9;
879
- valueLabel.fills = [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }];
880
- valueLabel.x = swatch.x;
881
- valueLabel.y = swatch.y + swatchSize + 16;
882
- specPage.appendChild(valueLabel);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883
  }
884
 
885
- var colorRows = Math.ceil(tokens.colors.length / colsPerRow);
886
- yOffset = colorY + colorRows * (swatchSize + swatchGap + 30) + sectionGap;
887
  }
888
 
889
- // === TYPOGRAPHY SECTION ===
890
- // IMPORTANT: Only use already-loaded Inter Regular/Bold for ALL text in the spec.
891
- // Never attempt to load custom fonts β€” they may not exist in the Figma environment
892
- // and would crash the entire spec generation. Font details are shown as labels.
893
- if (tokens.typography.length > 0) {
894
- var typoTitle = figma.createText();
895
- typoTitle.characters = 'TYPOGRAPHY';
896
- typoTitle.fontName = { family: 'Inter', style: headingStyle };
897
- typoTitle.fontSize = 24;
898
- typoTitle.x = xOffset;
899
- typoTitle.y = yOffset;
900
- specPage.appendChild(typoTitle);
901
- yOffset += 50;
902
-
903
- for (var ti = 0; ti < tokens.typography.length; ti++) {
904
- var typoToken = tokens.typography[ti];
905
- var value = typoToken.value;
906
 
907
- var fontFamily = value.fontFamily || 'Inter';
908
- if (fontFamily.indexOf(',') > -1) {
909
- fontFamily = fontFamily.split(',')[0].trim();
910
- }
911
- var fontSize = parseNumericValue(value.fontSize) || 16;
912
- var fontWeight = value.fontWeight || '400';
913
- var displaySize = Math.min(fontSize, 48); // Cap display size
914
-
915
- // Sample text β€” always use Inter (safe), size shows relative scale
916
- var sampleText = figma.createText();
917
- sampleText.fontName = { family: 'Inter', style: headingStyle };
918
- sampleText.fontSize = displaySize;
919
- sampleText.characters = typoToken.name.split('/').pop() || 'Sample Text';
920
- sampleText.x = xOffset;
921
- sampleText.y = yOffset;
922
- specPage.appendChild(sampleText);
923
-
924
- // Specs label β€” show the ACTUAL font specs as readable text
925
- var specsLabel = figma.createText();
926
- specsLabel.fontName = { family: 'Inter', style: 'Regular' };
927
- specsLabel.fontSize = 11;
928
- specsLabel.characters = fontFamily + ' Β· ' + fontSize + 'px Β· wt ' + fontWeight;
929
- specsLabel.fills = [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }];
930
- specsLabel.x = xOffset + 300;
931
- specsLabel.y = yOffset + 4;
932
- specPage.appendChild(specsLabel);
933
-
934
- yOffset += Math.max(displaySize, 24) + itemGap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
  }
936
 
937
- yOffset += sectionGap;
 
938
  }
939
 
940
- // === SPACING SECTION ===
 
 
 
 
 
941
  if (tokens.spacing.length > 0) {
942
- var spacingTitle = figma.createText();
943
- spacingTitle.characters = 'SPACING';
944
- spacingTitle.fontName = { family: 'Inter', style: headingStyle };
945
- spacingTitle.fontSize = 24;
946
- spacingTitle.x = xOffset;
947
- spacingTitle.y = yOffset;
948
- specPage.appendChild(spacingTitle);
949
- yOffset += 50;
950
-
951
- var spacingX = xOffset;
952
- for (var si = 0; si < Math.min(tokens.spacing.length, 12); si++) {
953
- var spacingToken = tokens.spacing[si];
954
- var spacingValue = parseNumericValue(spacingToken.value);
955
-
956
- // Spacing bar
957
- var bar = figma.createRectangle();
958
- bar.resize(Math.max(spacingValue, 4), 24);
959
- bar.x = spacingX;
960
- bar.y = yOffset;
961
- bar.cornerRadius = 4;
962
- bar.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.6, b: 1 } }];
963
- specPage.appendChild(bar);
964
-
965
- // Label
966
- var spLabel = figma.createText();
967
- spLabel.characters = spacingToken.name.split('/').pop() + ' = ' + spacingToken.value;
968
- spLabel.fontName = { family: 'Inter', style: 'Regular' };
969
- spLabel.fontSize = 11;
970
- spLabel.x = spacingX;
971
- spLabel.y = yOffset + 30;
972
- specPage.appendChild(spLabel);
973
-
974
- spacingX += Math.max(spacingValue, 40) + 24;
975
  }
976
 
977
- yOffset += 80 + sectionGap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978
  }
979
 
980
- // === BORDER RADIUS SECTION ===
 
 
981
  if (tokens.borderRadius.length > 0) {
982
- var radiusTitle = figma.createText();
983
- radiusTitle.characters = 'BORDER RADIUS';
984
- radiusTitle.fontName = { family: 'Inter', style: headingStyle };
985
- radiusTitle.fontSize = 24;
986
- radiusTitle.x = xOffset;
987
- radiusTitle.y = yOffset;
988
- specPage.appendChild(radiusTitle);
989
- yOffset += 50;
990
-
991
- var radiusX = xOffset;
992
  for (var ri = 0; ri < tokens.borderRadius.length; ri++) {
993
- var radiusToken = tokens.borderRadius[ri];
994
- var radiusValue = parseNumericValue(radiusToken.value);
995
 
996
- // Rounded rectangle
997
  var rect = figma.createRectangle();
998
- rect.resize(50, 50);
999
- rect.x = radiusX;
1000
- rect.y = yOffset;
1001
- rect.cornerRadius = Math.min(radiusValue, 25);
1002
- rect.fills = [{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.95 } }];
1003
- rect.strokes = [{ type: 'SOLID', color: { r: 0.8, g: 0.8, b: 0.8 } }];
1004
  rect.strokeWeight = 2;
1005
- specPage.appendChild(rect);
1006
-
1007
- // Label
1008
- var rLabel = figma.createText();
1009
- rLabel.characters = radiusToken.name.split('/').pop() + '\n' + radiusToken.value;
1010
- rLabel.fontName = { family: 'Inter', style: 'Regular' };
1011
- rLabel.fontSize = 10;
1012
- rLabel.textAlignHorizontal = 'CENTER';
1013
- rLabel.x = radiusX;
1014
- rLabel.y = yOffset + 56;
1015
- specPage.appendChild(rLabel);
1016
-
1017
- radiusX += 70;
1018
- }
1019
 
1020
- yOffset += 100 + sectionGap;
 
1021
  }
1022
 
1023
- // === SHADOWS SECTION ===
 
 
1024
  if (tokens.shadows.length > 0) {
1025
- var shadowTitle = figma.createText();
1026
- shadowTitle.characters = 'SHADOWS';
1027
- shadowTitle.fontName = { family: 'Inter', style: headingStyle };
1028
- shadowTitle.fontSize = 24;
1029
- shadowTitle.x = xOffset;
1030
- shadowTitle.y = yOffset;
1031
- specPage.appendChild(shadowTitle);
1032
- yOffset += 50;
1033
-
1034
- var shadowX = xOffset;
1035
- for (var shi = 0; shi < tokens.shadows.length; shi++) {
1036
- var shadowToken = tokens.shadows[shi];
1037
- var sv = shadowToken.value;
1038
 
1039
- var offsetXVal = parseNumericValue(sv.offsetX || sv.x || '0');
1040
- var offsetYVal = parseNumericValue(sv.offsetY || sv.y || '0');
1041
- var blurVal = parseNumericValue(sv.blur || '0');
1042
- var spreadVal = parseNumericValue(sv.spread || '0');
1043
- var shColor = parseColorToRGBA(sv.color || 'rgba(0,0,0,0.25)');
1044
 
1045
- // Shadow card
 
 
 
 
 
 
 
 
 
 
1046
  var card = figma.createRectangle();
1047
- card.resize(80, 80);
1048
- card.x = shadowX;
1049
- card.y = yOffset;
1050
- card.cornerRadius = 8;
1051
  card.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
1052
  card.effects = [{
1053
  type: 'DROP_SHADOW',
1054
- color: { r: shColor.r, g: shColor.g, b: shColor.b, a: shColor.a },
1055
- offset: { x: offsetXVal, y: offsetYVal },
1056
- radius: blurVal,
1057
- spread: spreadVal,
1058
  visible: true,
1059
  blendMode: 'NORMAL'
1060
  }];
1061
- specPage.appendChild(card);
1062
-
1063
- // Label
1064
- var shLabel = figma.createText();
1065
- shLabel.characters = shadowToken.name.split('/').pop();
1066
- shLabel.fontName = { family: 'Inter', style: 'Regular' };
1067
- shLabel.fontSize = 11;
1068
- shLabel.x = shadowX;
1069
- shLabel.y = yOffset + 90;
1070
- specPage.appendChild(shLabel);
1071
-
1072
- shadowX += 110;
1073
  }
1074
  }
1075
 
1076
- figma.ui.postMessage({
1077
- type: 'spec-complete',
1078
- message: 'Visual spec page created!'
1079
- });
 
 
 
 
 
 
 
 
1080
 
1081
  } catch (error) {
1082
  console.error('Error creating visual spec:', error);
 
40
  } : { r: 0, g: 0, b: 0 };
41
  }
42
 
43
+ // WCAG 2.1 contrast ratio calculation
44
+ function getRelativeLuminance(rgb) {
45
+ function sRGB(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
46
+ return 0.2126 * sRGB(rgb.r) + 0.7152 * sRGB(rgb.g) + 0.0722 * sRGB(rgb.b);
47
+ }
48
+ function getContrastRatio(hex1, hex2) {
49
+ var l1 = getRelativeLuminance(hexToRgb(hex1));
50
+ var l2 = getRelativeLuminance(hexToRgb(hex2));
51
+ var lighter = Math.max(l1, l2);
52
+ var darker = Math.min(l1, l2);
53
+ return (lighter + 0.05) / (darker + 0.05);
54
+ }
55
+ function getAAResult(hex) {
56
+ var onWhite = getContrastRatio(hex, '#ffffff');
57
+ var onBlack = getContrastRatio(hex, '#000000');
58
+ var bestRatio = Math.max(onWhite, onBlack);
59
+ var bestOn = onWhite >= onBlack ? 'white' : 'black';
60
+ return {
61
+ ratio: Math.round(bestRatio * 10) / 10,
62
+ passAA: bestRatio >= 4.5,
63
+ passAAA: bestRatio >= 7,
64
+ bestOn: bestOn
65
+ };
66
+ }
67
+
68
  // Helper to convert font weight to Figma font style name
69
  function getFontStyleFromWeight(weight) {
70
  var weightStr = String(weight).toLowerCase();
 
807
  }
808
 
809
  // ==========================================
810
+ // ==========================================
811
+ // VISUAL SPEC GENERATOR v3 β€” Material Design best-practices layout
812
+ // Each section in its own Figma FRAME. AA contrast badges. Prominent labels.
813
  // ==========================================
814
  if (msg.type === 'create-visual-spec') {
815
  try {
816
  var rawTokens = msg.tokens;
 
 
817
  if (!rawTokens) {
818
+ figma.ui.postMessage({ type: 'error', message: 'No tokens provided. Please load a JSON file first.' });
 
 
 
819
  return;
820
  }
821
 
 
822
  var tokens = normalizeTokens(rawTokens);
 
 
 
823
  var specPage = figma.currentPage;
824
  specPage.name = '🎨 Design System Spec';
825
+ while (specPage.children.length > 0) { specPage.children[0].remove(); }
826
+
827
+ // ── Constants (Material Design / Carbon inspired) ──
828
+ var FRAME_W = 1440;
829
+ var CONTENT_W = 1200;
830
+ var MARGIN = 120;
831
+ var SECTION_PAD = 48;
832
+ var SECTION_GAP = 40; // gap between frames on the page
833
+ var ITEM_GAP = 16;
834
+ var SWATCH_W = 160;
835
+ var SWATCH_H_COLOR = 64;
836
+ var SWATCH_META_H = 72;
837
+ var SWATCH_GAP = 12;
838
+
839
+ // ── Load fonts ──
840
+ var labelFont = { family: 'Inter', style: 'Regular' };
841
+ var boldFont = { family: 'Inter', style: 'Regular' };
842
+ await figma.loadFontAsync(labelFont);
843
+ try { await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); boldFont = { family: 'Inter', style: 'Bold' }; } catch (e) {}
844
+
845
+ // Try loading extracted font
846
+ var sampleFontFamily = 'Inter';
847
+ var sampleFont = labelFont;
848
+ var sampleFontBold = boldFont;
849
+ for (var fi = 0; fi < tokens.typography.length; fi++) {
850
+ var ff = (tokens.typography[fi].value.fontFamily || '').split(',')[0].trim();
851
+ if (ff && ff !== 'Inter' && ff !== 'sans-serif' && ff !== 'serif') { sampleFontFamily = ff; break; }
852
+ }
853
+ if (sampleFontFamily !== 'Inter') {
854
+ try {
855
+ await figma.loadFontAsync({ family: sampleFontFamily, style: 'Regular' });
856
+ sampleFont = { family: sampleFontFamily, style: 'Regular' };
857
+ try { await figma.loadFontAsync({ family: sampleFontFamily, style: 'Bold' }); sampleFontBold = { family: sampleFontFamily, style: 'Bold' }; } catch (e2) { sampleFontBold = sampleFont; }
858
+ } catch (e) { sampleFontFamily = 'Inter'; sampleFont = labelFont; sampleFontBold = boldFont; }
859
+ }
860
 
861
+ // Collect all frames β€” position them at the very end
862
+ var allFrames = [];
863
+
864
+ // ── Helper: create a section FRAME (positioned later) ──
865
+ function createSectionFrame(name, contentHeight) {
866
+ var frame = figma.createFrame();
867
+ frame.name = name;
868
+ frame.resize(FRAME_W, contentHeight);
869
+ frame.x = 0;
870
+ frame.y = 0; // will be repositioned at the end
871
+ frame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
872
+ frame.clipsContent = false;
873
+ specPage.appendChild(frame);
874
+ allFrames.push(frame);
875
+ return frame;
876
  }
877
 
878
+ // ── Helper: add text to a frame ──
879
+ function addText(frame, text, font, size, x, y, color) {
880
+ var t = figma.createText();
881
+ t.fontName = font;
882
+ t.fontSize = size;
883
+ t.characters = text;
884
+ t.fills = [{ type: 'SOLID', color: color || { r: 0.1, g: 0.1, b: 0.1 } }];
885
+ t.x = x; t.y = y;
886
+ frame.appendChild(t);
887
+ return t;
 
 
 
888
  }
889
 
890
+ var MUTED = { r: 0.5, g: 0.5, b: 0.5 };
891
+ var DARK = { r: 0.12, g: 0.12, b: 0.12 };
892
+ var BLUE = { r: 0.15, g: 0.45, b: 0.95 };
893
+
894
+ // ══════════════════════════════════════════════════════════
895
+ // TITLE FRAME
896
+ // ══════════════════════════════════════════════════════════
897
+ var titleFrame = createSectionFrame('Title', 120);
898
+ addText(titleFrame, 'Design System Specification', boldFont, 36, MARGIN, 32, DARK);
899
+ addText(titleFrame, 'Font: ' + sampleFontFamily, labelFont, 14, MARGIN, 80, MUTED);
900
+
901
+ // ══════════════════════════════════════════════════════════
902
+ // COLORS FRAME β€” grouped by category with AA badges
903
+ // ══════════════════════════════════════════════════════════
904
  if (tokens.colors.length > 0) {
905
+ // Group colors by category
906
+ var colorGroups = {};
907
+ var groupOrder = [];
908
+ for (var ci = 0; ci < tokens.colors.length; ci++) {
909
+ var cat = tokens.colors[ci].name.split('/')[0] || 'other';
910
+ if (!colorGroups[cat]) { colorGroups[cat] = []; groupOrder.push(cat); }
911
+ colorGroups[cat].push(tokens.colors[ci]);
912
+ }
913
+ // Sort: semantic first, then palette
914
+ var semOrder = ['brand', 'text', 'bg', 'background', 'border', 'feedback'];
915
+ var sortedGroups = [];
916
+ for (var so = 0; so < semOrder.length; so++) { if (colorGroups[semOrder[so]]) sortedGroups.push(semOrder[so]); }
917
+ for (var go = 0; go < groupOrder.length; go++) { if (sortedGroups.indexOf(groupOrder[go]) === -1) sortedGroups.push(groupOrder[go]); }
918
+
919
+ // Calculate initial height estimate (will be resized at end)
920
+ var colorsPerRow = Math.floor((CONTENT_W - 140 + SWATCH_GAP) / (SWATCH_W + SWATCH_GAP));
921
+ var totalColorH = 2000; // generous initial estimate, resized later
922
+
923
+ var colorFrame = createSectionFrame('Colors', totalColorH);
924
+ var cy = SECTION_PAD;
925
+
926
  // Section title
927
+ addText(colorFrame, 'COLORS', boldFont, 32, MARGIN, cy, DARK);
928
+ cy += 48;
929
+
930
+ // Helper to render a single color card at position
931
+ function renderColorCard(frame, ct, sx, sy) {
932
+ var swatch = figma.createRectangle();
933
+ swatch.resize(SWATCH_W, SWATCH_H_COLOR);
934
+ swatch.x = sx; swatch.y = sy;
935
+ swatch.topLeftRadius = 8; swatch.topRightRadius = 8;
936
+ swatch.fills = [{ type: 'SOLID', color: hexToRgb(ct.value) }];
937
+ frame.appendChild(swatch);
938
+
939
+ // AA badge
940
+ var aa = getAAResult(ct.value);
941
+ var badgeText = aa.passAA ? 'AA βœ“ ' + aa.ratio + ':1' : 'AA βœ— ' + aa.ratio + ':1';
942
+ var badgeColor = aa.bestOn === 'white' ? { r: 1, g: 1, b: 1 } : { r: 0, g: 0, b: 0 };
943
+ addText(frame, badgeText, labelFont, 10, sx + 8, sy + SWATCH_H_COLOR - 18, badgeColor);
944
+
945
+ // Metadata area
946
+ var metaBg = figma.createRectangle();
947
+ metaBg.resize(SWATCH_W, SWATCH_META_H);
948
+ metaBg.x = sx; metaBg.y = sy + SWATCH_H_COLOR;
949
+ metaBg.bottomLeftRadius = 8; metaBg.bottomRightRadius = 8;
950
+ metaBg.fills = [{ type: 'SOLID', color: { r: 0.97, g: 0.97, b: 0.98 } }];
951
+ metaBg.strokes = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }];
952
+ metaBg.strokeWeight = 1;
953
+ frame.appendChild(metaBg);
954
+
955
+ var nameParts = ct.name.split('/');
956
+ var displayName = nameParts.filter(function(p) { return p !== 'DEFAULT'; }).join('/');
957
+ addText(frame, displayName, boldFont, 11, sx + 10, sy + SWATCH_H_COLOR + 8, DARK);
958
+ addText(frame, ct.value.toUpperCase(), labelFont, 11, sx + 10, sy + SWATCH_H_COLOR + 26, MUTED);
959
+
960
+ var contrastText = aa.passAA ? 'AA Pass on ' + aa.bestOn : 'AA Fail (' + aa.ratio + ':1)';
961
+ var contrastColor = aa.passAA ? { r: 0.13, g: 0.55, b: 0.13 } : { r: 0.85, g: 0.18, b: 0.18 };
962
+ addText(frame, contrastText, labelFont, 10, sx + 10, sy + SWATCH_H_COLOR + 44, contrastColor);
963
+ }
964
 
965
+ var isSemantic = { 'brand': 1, 'text': 1, 'bg': 1, 'background': 1, 'border': 1, 'feedback': 1 };
966
+ var CARD_H = SWATCH_H_COLOR + SWATCH_META_H + ITEM_GAP;
967
+
968
+ for (var gi = 0; gi < sortedGroups.length; gi++) {
969
+ var gName = sortedGroups[gi];
970
+ var gColors = colorGroups[gName];
971
+
972
+ // Group heading
973
+ addText(colorFrame, gName.charAt(0).toUpperCase() + gName.slice(1), boldFont, 18, MARGIN, cy, DARK);
974
+ cy += 36;
975
+
976
+ if (isSemantic[gName]) {
977
+ // ── SEMANTIC LAYOUT: sub-group by 2nd path segment, each on own row ──
978
+ // e.g. brand/primary/DEFAULT, brand/primary/50 β†’ sub-group "primary"
979
+ // feedback/error/DEFAULT, feedback/info/DEFAULT β†’ sub-groups "error", "info"
980
+ var subGroups = {};
981
+ var subOrder = [];
982
+ for (var si = 0; si < gColors.length; si++) {
983
+ var sp = gColors[si].name.split('/');
984
+ var subKey = sp.length > 1 ? sp[1] : sp[0];
985
+ if (!subGroups[subKey]) { subGroups[subKey] = []; subOrder.push(subKey); }
986
+ subGroups[subKey].push(gColors[si]);
987
+ }
988
+
989
+ for (var ski = 0; ski < subOrder.length; ski++) {
990
+ var subName = subOrder[ski];
991
+ var subColors = subGroups[subName];
992
+
993
+ // Row label on the left
994
+ addText(colorFrame, subName, boldFont, 13, MARGIN, cy + 30, MUTED);
995
+
996
+ // Swatches start after label
997
+ var labelW = 140;
998
+ for (var sci = 0; sci < subColors.length; sci++) {
999
+ var scx = MARGIN + labelW + sci * (SWATCH_W + SWATCH_GAP);
1000
+ renderColorCard(colorFrame, subColors[sci], scx, cy);
1001
+ }
1002
+
1003
+ cy += CARD_H + 8;
1004
+ }
1005
+ } else {
1006
+ // ── PALETTE LAYOUT: horizontal grid (50-900 ramp) ──
1007
+ for (var ci2 = 0; ci2 < gColors.length; ci2++) {
1008
+ var ct = gColors[ci2];
1009
+ var col = ci2 % colorsPerRow;
1010
+ var row = Math.floor(ci2 / colorsPerRow);
1011
+ var sx = MARGIN + col * (SWATCH_W + SWATCH_GAP);
1012
+ var sy = cy + row * CARD_H;
1013
+ renderColorCard(colorFrame, ct, sx, sy);
1014
+ }
1015
+
1016
+ var gRows = Math.ceil(gColors.length / colorsPerRow);
1017
+ cy += gRows * CARD_H + 16;
1018
+ }
1019
+
1020
+ cy += 8; // gap between groups
1021
  }
1022
 
1023
+ // Resize color frame to actual content
1024
+ colorFrame.resize(FRAME_W, cy + SECTION_PAD);
1025
  }
1026
 
1027
+ // ══════════════════════════════════════════════════════════
1028
+ // TYPOGRAPHY DESKTOP FRAME
1029
+ // ══════════════════════════════════════════════════════════
1030
+ var desktopTypo = [];
1031
+ var mobileTypo = [];
1032
+ for (var tti = 0; tti < tokens.typography.length; tti++) {
1033
+ if (tokens.typography[tti].name.toLowerCase().indexOf('mobile') > -1) {
1034
+ mobileTypo.push(tokens.typography[tti]);
1035
+ } else {
1036
+ desktopTypo.push(tokens.typography[tti]);
1037
+ }
1038
+ }
 
 
 
 
 
1039
 
1040
+ function getSampleText(name) {
1041
+ var n = name.toLowerCase();
1042
+ if (n.indexOf('display') > -1) return 'The quick brown fox jumps over the lazy dog';
1043
+ if (n.indexOf('heading') > -1) return 'The quick brown fox jumps over';
1044
+ if (n.indexOf('body') > -1) return 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.';
1045
+ if (n.indexOf('caption') > -1) return 'The quick brown fox jumps over the lazy dog';
1046
+ if (n.indexOf('overline') > -1) return 'THE QUICK BROWN FOX JUMPS';
1047
+ return 'The quick brown fox jumps over the lazy dog';
1048
+ }
1049
+
1050
+ function renderTypoFrame(typoList, frameName) {
1051
+ if (typoList.length === 0) return;
1052
+
1053
+ // Use generous initial height β€” will be resized to actual content
1054
+ var estH = 3000;
1055
+
1056
+ var frame = createSectionFrame(frameName, estH);
1057
+ var fy = SECTION_PAD;
1058
+
1059
+ addText(frame, frameName.toUpperCase(), boldFont, 32, MARGIN, fy, DARK);
1060
+ fy += 52;
1061
+
1062
+ // Column headers
1063
+ var COL_NAME = MARGIN;
1064
+ var COL_SAMPLE = MARGIN + 180;
1065
+ var COL_SPECS = MARGIN + 760;
1066
+
1067
+ addText(frame, 'Token', boldFont, 12, COL_NAME, fy, MUTED);
1068
+ addText(frame, 'Sample', boldFont, 12, COL_SAMPLE, fy, MUTED);
1069
+ addText(frame, 'Specifications', boldFont, 12, COL_SPECS, fy, MUTED);
1070
+ fy += 28;
1071
+
1072
+ // Divider
1073
+ var div = figma.createRectangle();
1074
+ div.resize(CONTENT_W, 1); div.x = MARGIN; div.y = fy;
1075
+ div.fills = [{ type: 'SOLID', color: { r: 0.88, g: 0.88, b: 0.88 } }];
1076
+ frame.appendChild(div);
1077
+ fy += 16;
1078
+
1079
+ for (var ti = 0; ti < typoList.length; ti++) {
1080
+ var tt = typoList[ti];
1081
+ var val = tt.value;
1082
+ var fFamily = (val.fontFamily || 'Inter').split(',')[0].trim();
1083
+ var fSize = parseNumericValue(val.fontSize) || 16;
1084
+ var fWeight = val.fontWeight || '400';
1085
+ var fLH = val.lineHeight || '1.5';
1086
+ var displaySize = Math.min(fSize, 56);
1087
+
1088
+ // Token name column
1089
+ var tierParts = tt.name.split('/');
1090
+ var tierName = tierParts.filter(function(p) { return p !== 'desktop' && p !== 'mobile'; }).join('.');
1091
+ addText(frame, tierName, boldFont, 13, COL_NAME, fy + 4, DARK);
1092
+
1093
+ // Sample text in actual font
1094
+ var useBold = (tierName.indexOf('display') > -1 || tierName.indexOf('heading') > -1);
1095
+ var sampleText = getSampleText(tt.name);
1096
+ var sample = figma.createText();
1097
+ sample.fontName = useBold ? sampleFontBold : sampleFont;
1098
+ sample.fontSize = displaySize;
1099
+ sample.textAutoResize = 'HEIGHT';
1100
+ sample.resize(540, displaySize * 2);
1101
+ sample.characters = sampleText;
1102
+ sample.x = COL_SAMPLE; sample.y = fy;
1103
+ frame.appendChild(sample);
1104
+
1105
+ // Manual height calculation β€” sample.height is unreliable in plugin API
1106
+ var avgCharWidth = displaySize * 0.52;
1107
+ var sampleColW = 540;
1108
+ var charsPerLine = Math.max(1, Math.floor(sampleColW / avgCharWidth));
1109
+ var numLines = Math.ceil(sampleText.length / charsPerLine);
1110
+ var lineH = displaySize * 1.4;
1111
+ var sampleH = Math.max(numLines * lineH, displaySize * 1.5);
1112
+
1113
+ // Specs column β€” stacked chips
1114
+ var specY = fy;
1115
+ addText(frame, 'Size: ' + fSize + 'px', boldFont, 12, COL_SPECS, specY, DARK);
1116
+ specY += 18;
1117
+ addText(frame, 'Weight: ' + fWeight, labelFont, 12, COL_SPECS, specY, MUTED);
1118
+ specY += 18;
1119
+ addText(frame, 'Line Height: ' + fLH, labelFont, 12, COL_SPECS, specY, MUTED);
1120
+ specY += 18;
1121
+ addText(frame, 'Font: ' + fFamily, labelFont, 12, COL_SPECS, specY, BLUE);
1122
+
1123
+ // Row height = max of (sample text height, spec labels height, minimum 40px)
1124
+ var specsH = specY - fy + 24;
1125
+ var rowH = Math.max(sampleH + 12, specsH, 40);
1126
+ fy += rowH + 8;
1127
+
1128
+ // Row separator
1129
+ var sep = figma.createRectangle();
1130
+ sep.resize(CONTENT_W, 1); sep.x = MARGIN; sep.y = fy;
1131
+ sep.fills = [{ type: 'SOLID', color: { r: 0.94, g: 0.94, b: 0.94 } }];
1132
+ frame.appendChild(sep);
1133
+ fy += 12;
1134
  }
1135
 
1136
+ // Resize frame to actual content
1137
+ frame.resize(FRAME_W, fy + SECTION_PAD);
1138
  }
1139
 
1140
+ renderTypoFrame(desktopTypo, 'Typography β€” Desktop');
1141
+ renderTypoFrame(mobileTypo, 'Typography β€” Mobile');
1142
+
1143
+ // ══════════════════════════════════════════════════════════
1144
+ // SPACING FRAME β€” Desktop & Mobile side by side, bars not squares
1145
+ // ══════════════════════════════════════════════════════════
1146
  if (tokens.spacing.length > 0) {
1147
+ var dSpacing = [];
1148
+ var mSpacing = [];
1149
+ for (var spi = 0; spi < tokens.spacing.length; spi++) {
1150
+ if (tokens.spacing[spi].name.toLowerCase().indexOf('mobile') > -1) {
1151
+ mSpacing.push(tokens.spacing[spi]);
1152
+ } else {
1153
+ dSpacing.push(tokens.spacing[spi]);
1154
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1155
  }
1156
 
1157
+ var maxItems = Math.max(dSpacing.length, mSpacing.length);
1158
+ var spFrameH = SECTION_PAD + 60 + maxItems * 48 + SECTION_PAD;
1159
+
1160
+ var spFrame = createSectionFrame('Spacing', spFrameH);
1161
+ var spy = SECTION_PAD;
1162
+
1163
+ addText(spFrame, 'SPACING', boldFont, 32, MARGIN, spy, DARK);
1164
+ spy += 52;
1165
+
1166
+ // Render spacing column as horizontal bars (Carbon-style)
1167
+ function renderSpacingBars(list, startX, title, y0) {
1168
+ addText(spFrame, title, boldFont, 16, startX, y0, BLUE);
1169
+ var ly = y0 + 32;
1170
+
1171
+ for (var si = 0; si < Math.min(list.length, 12); si++) {
1172
+ var sp = list[si];
1173
+ var spVal = parseNumericValue(sp.value);
1174
+ var barW = Math.max(spVal * 3, 8); // Scale 3x for visibility
1175
+ barW = Math.min(barW, 400); // Cap
1176
+
1177
+ // Token label
1178
+ var spParts = sp.name.split('/');
1179
+ var spDisplayName = spParts.filter(function(p) { return p !== 'desktop' && p !== 'mobile'; }).join('/');
1180
+ addText(spFrame, spDisplayName, labelFont, 12, startX, ly + 4, DARK);
1181
+
1182
+ // Bar
1183
+ var bar = figma.createRectangle();
1184
+ bar.resize(barW, 24);
1185
+ bar.x = startX + 120;
1186
+ bar.y = ly;
1187
+ bar.cornerRadius = 4;
1188
+ bar.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.55, b: 1 } }];
1189
+ spFrame.appendChild(bar);
1190
+
1191
+ // Value label
1192
+ addText(spFrame, sp.value, labelFont, 12, startX + 120 + barW + 12, ly + 4, MUTED);
1193
+
1194
+ ly += 40;
1195
+ }
1196
+ return ly;
1197
+ }
1198
+
1199
+ var dEnd = renderSpacingBars(dSpacing, MARGIN, 'πŸ–₯ Desktop', spy);
1200
+ var mEnd = renderSpacingBars(mSpacing, MARGIN + 560, 'πŸ“± Mobile', spy);
1201
+
1202
+ spFrame.resize(FRAME_W, Math.max(dEnd, mEnd) + SECTION_PAD);
1203
  }
1204
 
1205
+ // ══════════════════════════════════════════════════════════
1206
+ // BORDER RADIUS FRAME
1207
+ // ══════════════════════════════════════════════════════════
1208
  if (tokens.borderRadius.length > 0) {
1209
+ var radFrameH = SECTION_PAD + 60 + 120 + SECTION_PAD;
1210
+ var radFrame = createSectionFrame('Border Radius', radFrameH);
1211
+ var ry = SECTION_PAD;
1212
+
1213
+ addText(radFrame, 'BORDER RADIUS', boldFont, 32, MARGIN, ry, DARK);
1214
+ ry += 52;
1215
+
1216
+ var rx = MARGIN;
 
 
1217
  for (var ri = 0; ri < tokens.borderRadius.length; ri++) {
1218
+ var rToken = tokens.borderRadius[ri];
1219
+ var rVal = parseNumericValue(rToken.value);
1220
 
 
1221
  var rect = figma.createRectangle();
1222
+ rect.resize(64, 64);
1223
+ rect.x = rx; rect.y = ry;
1224
+ rect.cornerRadius = Math.min(rVal, 32);
1225
+ rect.fills = [{ type: 'SOLID', color: { r: 0.93, g: 0.93, b: 0.96 } }];
1226
+ rect.strokes = [{ type: 'SOLID', color: { r: 0.75, g: 0.75, b: 0.82 } }];
 
1227
  rect.strokeWeight = 2;
1228
+ radFrame.appendChild(rect);
1229
+
1230
+ // Name
1231
+ addText(radFrame, rToken.name.split('/').pop(), boldFont, 11, rx, ry + 72, DARK);
1232
+ // Value
1233
+ addText(radFrame, rToken.value, labelFont, 11, rx, ry + 88, MUTED);
 
 
 
 
 
 
 
 
1234
 
1235
+ rx += 90;
1236
+ }
1237
  }
1238
 
1239
+ // ══════════════════════════════════════════════════════════
1240
+ // SHADOWS FRAME β€” on light gray background (MD pattern)
1241
+ // ══════════════════════════════════════════════════════════
1242
  if (tokens.shadows.length > 0) {
1243
+ var shFrameH = SECTION_PAD + 60 + 200 + SECTION_PAD;
1244
+ var shFrame = createSectionFrame('Shadows', shFrameH);
1245
+ // Light gray background for shadow visibility
1246
+ shFrame.fills = [{ type: 'SOLID', color: { r: 0.96, g: 0.96, b: 0.96 } }];
 
 
 
 
 
 
 
 
 
1247
 
1248
+ var shy = SECTION_PAD;
1249
+ addText(shFrame, 'SHADOWS / ELEVATION', boldFont, 32, MARGIN, shy, DARK);
1250
+ shy += 52;
 
 
1251
 
1252
+ var shx = MARGIN;
1253
+ for (var shi = 0; shi < tokens.shadows.length; shi++) {
1254
+ var shToken = tokens.shadows[shi];
1255
+ var sv = shToken.value;
1256
+ var oxV = parseNumericValue(sv.offsetX || sv.x || '0');
1257
+ var oyV = parseNumericValue(sv.offsetY || sv.y || '0');
1258
+ var blV = parseNumericValue(sv.blur || '0');
1259
+ var spV = parseNumericValue(sv.spread || '0');
1260
+ var shC = parseColorToRGBA(sv.color || 'rgba(0,0,0,0.25)');
1261
+
1262
+ // White card with shadow
1263
  var card = figma.createRectangle();
1264
+ card.resize(140, 140);
1265
+ card.x = shx; card.y = shy;
1266
+ card.cornerRadius = 12;
 
1267
  card.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
1268
  card.effects = [{
1269
  type: 'DROP_SHADOW',
1270
+ color: { r: shC.r, g: shC.g, b: shC.b, a: shC.a },
1271
+ offset: { x: oxV, y: oyV },
1272
+ radius: blV,
1273
+ spread: spV,
1274
  visible: true,
1275
  blendMode: 'NORMAL'
1276
  }];
1277
+ shFrame.appendChild(card);
1278
+
1279
+ // Level name centered on card
1280
+ addText(shFrame, shToken.name.split('/').pop(), boldFont, 14, shx + 20, shy + 30, DARK);
1281
+
1282
+ // Specs on card
1283
+ addText(shFrame, 'blur: ' + blV + 'px', labelFont, 11, shx + 20, shy + 56, MUTED);
1284
+ addText(shFrame, 'y: ' + oyV + 'px', labelFont, 11, shx + 20, shy + 72, MUTED);
1285
+ addText(shFrame, 'spread: ' + spV + 'px', labelFont, 11, shx + 20, shy + 88, MUTED);
1286
+
1287
+ shx += 180;
 
1288
  }
1289
  }
1290
 
1291
+ // ══════════════════════════════════════════════════════════
1292
+ // POSITION ALL FRAMES β€” stack HORIZONTALLY with proper spacing
1293
+ // This runs AFTER all frames are created and resized to actual content.
1294
+ // ══════════════════════════════════════════════════════════
1295
+ var stackX = 0;
1296
+ for (var fi = 0; fi < allFrames.length; fi++) {
1297
+ allFrames[fi].x = stackX;
1298
+ allFrames[fi].y = 0;
1299
+ stackX += allFrames[fi].width + SECTION_GAP;
1300
+ }
1301
+
1302
+ figma.ui.postMessage({ type: 'spec-complete', message: 'Visual spec created with ' + allFrames.length + ' section frames!' });
1303
 
1304
  } catch (error) {
1305
  console.error('Error creating visual spec:', error);
storage/benchmark_cache.json CHANGED
@@ -15,7 +15,7 @@
15
  "colors": {},
16
  "radius": {},
17
  "shadows": {},
18
- "fetched_at": "2026-02-22T12:38:30.430328",
19
  "confidence": "high",
20
  "best_for": []
21
  }
 
15
  "colors": {},
16
  "radius": {},
17
  "shadows": {},
18
+ "fetched_at": "2026-02-22T17:56:25.598103",
19
  "confidence": "high",
20
  "best_for": []
21
  }