riazmo Claude Opus 4.6 commited on
Commit
d0d3d7e
·
1 Parent(s): abab3e7

v3.1: Rule-based color classifier — kill LLM naming

Browse files

- NEW core/color_classifier.py: 100% deterministic color classification
- Aggressive dedup (RGB dist < 30 within same category)
- Capped categories: brand(3), text(3), bg(3), border(3), feedback(4)
- Supports semantic/tailwind/material naming conventions
- Every decision logged with evidence
- app.py: Convention picker dropdown + preview button in Stage 3
- Export functions now use classifier instead of LLM naming_map
- Removed _get_semantic_color_overrides() / _consolidate_colors() dependency
- AURORA demoted to advisory-only (no more naming_map output)
- CLAUDE.md updated with v3.1 architecture

Fixes: "nonary/decenary" Latin ordinal naming, raw hex keys,
mixed conventions, 3-way naming merge chaos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (5) hide show
  1. CLAUDE.md +38 -4
  2. agents/llm_agents.py +10 -12
  3. app.py +83 -27
  4. core/color_classifier.py +678 -0
  5. output_json/file (18).json +584 -0
CLAUDE.md CHANGED
@@ -1,15 +1,49 @@
1
- # Design System Extractor v2 — 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 (free, no LLM)
8
- 2. **Stage 2 (LLM-powered)**: Brand identification → Benchmark comparison → Best practices → Final synthesis
9
 
10
  ---
11
 
12
- ## CURRENT STATUS: BROKEN NEEDS RETHINK
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  ### What's Wrong (observed from real site tests)
15
 
 
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
 
12
+ ## v3.1 FIX: RULE-BASED COLOR NAMING (Feb 2026)
13
+
14
+ ### What Changed
15
+ - **KILLED LLM color naming entirely.** New `core/color_classifier.py` handles all color naming with 100% deterministic rules.
16
+ - **Aggressive deduplication**: Colors within RGB distance < 30 AND same category get merged (e.g., 13 text grays → 3)
17
+ - **Capped categories**: brand (max 3), text (max 3), bg (max 3), border (max 3), feedback (max 4), palette (remaining)
18
+ - **User-selectable naming convention**: semantic, tailwind, or material — chosen BEFORE export
19
+ - **Preview before export**: User sees classification + decision log before committing
20
+ - **Every decision logged**: `[DEDUP]`, `[CLASSIFY]`, `[CAP]`, `[NAME]` with evidence
21
+
22
+ ### How Classification Works (No LLM)
23
+ ```
24
+ CSS Evidence → Category:
25
+ background-color on <button> + saturated + freq>5 → BRAND
26
+ color on <p>/<span> + low saturation → TEXT
27
+ background-color on <div>/<body> + neutral → BG
28
+ border-color + low saturation → BORDER
29
+ red hue + sat>0.6 + low freq → FEEDBACK (error)
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
40
+ - `app.py` — Export functions use classifier instead of LLM naming; convention picker in UI
41
+ - `agents/llm_agents.py` — AURORA prompt updated to advisory-only
42
+ - `CLAUDE.md` — This documentation
43
+
44
+ ---
45
+
46
+ ## PREVIOUS STATUS (v3.0 and earlier): BROKEN — RETHINK COMPLETED
47
 
48
  ### What's Wrong (observed from real site tests)
49
 
agents/llm_agents.py CHANGED
@@ -303,34 +303,33 @@ def _extract_hexes(tokens: dict) -> list:
303
  class BrandIdentifierAgent:
304
  """
305
  AURORA — Senior Brand & Visual Identity Analyst.
306
- ReAct on ALL token types. Names ALL colors.
 
307
  Model: Qwen 72B · Temperature: 0.4
308
  """
309
 
310
  SYSTEM_PROMPT = """You are AURORA, a Senior Brand & Visual Identity Analyst.
311
 
 
 
 
 
312
  ## REASONING FRAMEWORK (ReAct)
313
  Structure your response with explicit reasoning steps.
314
  For each area: THINK → ACT → OBSERVE → VERIFY.
315
 
316
  ## ANALYZE ALL TOKEN TYPES:
317
 
318
- ### 1. COLORS (Primary focus)
319
- - Identify brand primary/secondary/accent from usage + role_hints
320
- - Name EVERY color: color.{role}.{sub} or color.{hue}.{shade}
321
- - Shades MUST be numeric (50-900), NEVER words (light/dark/base)
322
- - Role colors: color.brand.primary, color.text.primary, color.bg.primary
323
- - Palette colors: color.blue.500, color.neutral.200
324
-
325
- ### 2. TYPOGRAPHY — Identify heading vs body hierarchy, font pairing
326
  ### 3. SPACING — Identify grid system, note consistency
327
  ### 4. RADIUS — Identify radius strategy (sharp/rounded/pill)
328
  ### 5. SHADOWS — Identify elevation strategy, blur progression
329
 
330
  ## QUALITY RULES
331
- - naming_map MUST include EVERY hex color — no orphans
332
  - Brand Primary MUST cite usage evidence (e.g. "47x on buttons")
333
  - Cohesion 1-10: most sites score 5-7. Use the full range.
 
334
 
335
  ## OUTPUT (JSON)
336
 
@@ -339,7 +338,6 @@ For each area: THINK → ACT → OBSERVE → VERIFY.
339
  {"step": "THINK", "area": "colors", "content": "..."},
340
  {"step": "ACT", "area": "colors", "content": "..."},
341
  {"step": "OBSERVE", "area": "typography", "content": "..."},
342
- {"step": "ACT", "area": "typography", "content": "..."},
343
  {"step": "ACT", "area": "spacing", "content": "..."},
344
  {"step": "ACT", "area": "radius", "content": "..."},
345
  {"step": "ACT", "area": "shadows", "content": "..."},
@@ -351,7 +349,7 @@ For each area: THINK → ACT → OBSERVE → VERIFY.
351
  "palette_strategy": "complementary|analogous|triadic|monochromatic|random",
352
  "cohesion_score": N,
353
  "cohesion_notes": "...",
354
- "naming_map": {"#hex1": "color.brand.primary", "#hex2": "color.blue.500", ...},
355
  "typography_notes": "Heading: Inter 700, Body: Inter 400. Clean hierarchy.",
356
  "spacing_notes": "8px grid, 92% aligned.",
357
  "radius_notes": "Rounded style: 4px inputs, 8px cards.",
 
303
  class BrandIdentifierAgent:
304
  """
305
  AURORA — Senior Brand & Visual Identity Analyst.
306
+ v3.1: ADVISORY ONLY does NOT name colors (rule-based classifier does that).
307
+ Provides brand insights, palette strategy, cohesion assessment.
308
  Model: Qwen 72B · Temperature: 0.4
309
  """
310
 
311
  SYSTEM_PROMPT = """You are AURORA, a Senior Brand & Visual Identity Analyst.
312
 
313
+ ## YOUR ROLE (v3.1: Advisory Only)
314
+ Color NAMING is handled by a rule-based classifier. Do NOT output naming_map.
315
+ Your job is to provide INSIGHTS about the brand identity and design cohesion.
316
+
317
  ## REASONING FRAMEWORK (ReAct)
318
  Structure your response with explicit reasoning steps.
319
  For each area: THINK → ACT → OBSERVE → VERIFY.
320
 
321
  ## ANALYZE ALL TOKEN TYPES:
322
 
323
+ ### 1. COLORS — Identify brand strategy (complementary? analogous? monochromatic?)
324
+ ### 2. TYPOGRAPHY — Identify heading vs body hierarchy, font pairing quality
 
 
 
 
 
 
325
  ### 3. SPACING — Identify grid system, note consistency
326
  ### 4. RADIUS — Identify radius strategy (sharp/rounded/pill)
327
  ### 5. SHADOWS — Identify elevation strategy, blur progression
328
 
329
  ## QUALITY RULES
 
330
  - Brand Primary MUST cite usage evidence (e.g. "47x on buttons")
331
  - Cohesion 1-10: most sites score 5-7. Use the full range.
332
+ - Do NOT invent names. Focus on analysis and insights.
333
 
334
  ## OUTPUT (JSON)
335
 
 
338
  {"step": "THINK", "area": "colors", "content": "..."},
339
  {"step": "ACT", "area": "colors", "content": "..."},
340
  {"step": "OBSERVE", "area": "typography", "content": "..."},
 
341
  {"step": "ACT", "area": "spacing", "content": "..."},
342
  {"step": "ACT", "area": "radius", "content": "..."},
343
  {"step": "ACT", "area": "shadows", "content": "..."},
 
349
  "palette_strategy": "complementary|analogous|triadic|monochromatic|random",
350
  "cohesion_score": N,
351
  "cohesion_notes": "...",
352
+ "naming_map": {},
353
  "typography_notes": "Heading: Inter 700, Body: Inter 400. Clean hierarchy.",
354
  "spacing_notes": "8px grid, 92% aligned.",
355
  "radius_notes": "Rounded style: 4px inputs, 8px cards.",
app.py CHANGED
@@ -42,6 +42,7 @@ class AppState:
42
  self.mobile_normalized = None # NormalizedTokens
43
  self.upgrade_recommendations = None # UpgradeRecommendations
44
  self.selected_upgrades = {} # User selections
 
45
  self.logs = []
46
 
47
  def log(self, message: str):
@@ -3101,7 +3102,31 @@ def _consolidate_colors(colors_dict: dict, overrides: dict, max_colors: int = 30
3101
  return result
3102
 
3103
 
3104
- def export_stage1_json():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3105
  """Export Stage 1 tokens (as-is extraction) to W3C DTCG format."""
3106
  if not state.desktop_normalized:
3107
  gr.Warning("No tokens extracted yet. Complete Stage 1 extraction first.")
@@ -3119,16 +3144,15 @@ def export_stage1_json():
3119
  # COLORS — Nested structure with $value, $type, $description
3120
  # =========================================================================
3121
  if state.desktop_normalized and state.desktop_normalized.colors:
3122
- overrides = _get_semantic_color_overrides()
3123
- consolidated = _consolidate_colors(
3124
- state.desktop_normalized.colors, overrides, max_colors=30,
 
 
3125
  )
3126
- for flat_key, entry in consolidated.items():
3127
- # flat_key = "color.brand.primary"
3128
- source = entry.get("source", "extracted")
3129
- source_label = "LLM Semantic" if source == "semantic" else "Auto-Generated" if source == "detected" else "Extracted"
3130
- dtcg_token = _to_dtcg_token(entry["value"], "color", description=source_label)
3131
- _flat_key_to_nested(flat_key, dtcg_token, result)
3132
  token_count += 1
3133
 
3134
  # =========================================================================
@@ -3238,7 +3262,7 @@ def export_stage1_json():
3238
  return json_str
3239
 
3240
 
3241
- def export_tokens_json():
3242
  """Export final tokens with selected upgrades applied - FLAT structure for Figma Tokens Studio."""
3243
  if not state.desktop_normalized:
3244
  gr.Warning("No tokens extracted yet. Complete Stage 1 extraction first.")
@@ -3280,34 +3304,37 @@ def export_tokens_json():
3280
  primary_font = fonts_info.get("primary", "sans-serif")
3281
 
3282
  # =========================================================================
3283
- # COLORS — Consolidated with semantic naming + optional ramps
3284
  # =========================================================================
3285
  if state.desktop_normalized and state.desktop_normalized.colors:
3286
  from core.color_utils import generate_color_ramp
 
3287
 
3288
- overrides = _get_semantic_color_overrides()
3289
- consolidated = _consolidate_colors(
3290
- state.desktop_normalized.colors, overrides, max_colors=30,
 
3291
  )
3292
 
3293
- for flat_key, entry in consolidated.items():
 
3294
  if apply_ramps:
3295
  try:
3296
- ramp = generate_color_ramp(entry["value"])
3297
  shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"]
3298
  for i, shade in enumerate(shades):
3299
  if i < len(ramp):
3300
  shade_key = f"{flat_key}.{shade}"
3301
- color_val = ramp[i] if isinstance(ramp[i], str) else ramp[i].get("hex", entry["value"])
3302
  dtcg_token = _to_dtcg_token(color_val, "color")
3303
  _flat_key_to_nested(shade_key, dtcg_token, result)
3304
  token_count += 1
3305
  except (ValueError, KeyError, TypeError, IndexError):
3306
- dtcg_token = _to_dtcg_token(entry["value"], "color")
3307
  _flat_key_to_nested(flat_key, dtcg_token, result)
3308
  token_count += 1
3309
  else:
3310
- dtcg_token = _to_dtcg_token(entry["value"], "color")
3311
  _flat_key_to_nested(flat_key, dtcg_token, result)
3312
  token_count += 1
3313
 
@@ -4500,12 +4527,28 @@ def create_ui():
4500
  gr.Markdown("Export your finalized design tokens as JSON, compatible with **Figma Tokens Studio**.",
4501
  elem_classes=["section-desc"])
4502
  gr.Markdown("""
4503
- - **Stage 1 JSON (As-Is):** Raw extracted tokens with no modifications useful for archival or baseline comparison. Includes desktop and mobile viewport variants.
4504
- - **Final JSON (Upgraded):** Tokens with your selected improvements applied (type scale, spacing grid, color ramps, and accepted LLM recommendations). **This is the recommended export.**
4505
-
4506
- Copy the JSON output below or save it as a `.json` file for import into Figma.
4507
  """, elem_classes=["section-desc"])
4508
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4509
  with gr.Row():
4510
  export_stage1_btn = gr.Button("📥 Export Stage 1 (As-Is)", variant="secondary")
4511
  export_final_btn = gr.Button("📥 Export Final (Upgraded)", variant="primary")
@@ -4514,9 +4557,22 @@ Copy the JSON output below or save it as a `.json` file for import into Figma.
4514
  "Copy the contents or save as a `.json` file.*",
4515
  elem_classes=["section-desc"])
4516
  export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
4517
-
4518
- export_stage1_btn.click(export_stage1_json, outputs=[export_output])
4519
- export_final_btn.click(export_tokens_json, outputs=[export_output])
 
 
 
 
 
 
 
 
 
 
 
 
 
4520
 
4521
  # =================================================================
4522
  # EVENT HANDLERS
 
42
  self.mobile_normalized = None # NormalizedTokens
43
  self.upgrade_recommendations = None # UpgradeRecommendations
44
  self.selected_upgrades = {} # User selections
45
+ self.color_classification = None # ClassificationResult from rule-based classifier
46
  self.logs = []
47
 
48
  def log(self, message: str):
 
3102
  return result
3103
 
3104
 
3105
+ def preview_color_classification(convention: str = "semantic"):
3106
+ """Preview color classification before export. 100% rule-based, no LLM."""
3107
+ from core.color_classifier import classify_colors, generate_classification_preview
3108
+
3109
+ if not state.desktop_normalized or not state.desktop_normalized.colors:
3110
+ return "⚠️ No colors extracted yet. Run Stage 1 extraction first."
3111
+
3112
+ result = classify_colors(
3113
+ state.desktop_normalized.colors,
3114
+ convention=convention or "semantic",
3115
+ log_callback=state.log,
3116
+ )
3117
+
3118
+ # Store for use by export functions
3119
+ state.color_classification = result
3120
+
3121
+ preview = generate_classification_preview(result)
3122
+
3123
+ # Also append the decision log
3124
+ log_section = "\n\n📋 DECISION LOG:\n" + "\n".join(result.log)
3125
+
3126
+ return preview + log_section
3127
+
3128
+
3129
+ def export_stage1_json(convention: str = "semantic"):
3130
  """Export Stage 1 tokens (as-is extraction) to W3C DTCG format."""
3131
  if not state.desktop_normalized:
3132
  gr.Warning("No tokens extracted yet. Complete Stage 1 extraction first.")
 
3144
  # COLORS — Nested structure with $value, $type, $description
3145
  # =========================================================================
3146
  if state.desktop_normalized and state.desktop_normalized.colors:
3147
+ from core.color_classifier import classify_colors
3148
+ classification = classify_colors(
3149
+ state.desktop_normalized.colors,
3150
+ convention=convention or "semantic",
3151
+ log_callback=state.log,
3152
  )
3153
+ for c in classification.colors:
3154
+ dtcg_token = _to_dtcg_token(c.hex, "color", description=f"Rule-based: {c.category}")
3155
+ _flat_key_to_nested(c.token_name, dtcg_token, result)
 
 
 
3156
  token_count += 1
3157
 
3158
  # =========================================================================
 
3262
  return json_str
3263
 
3264
 
3265
+ def export_tokens_json(convention: str = "semantic"):
3266
  """Export final tokens with selected upgrades applied - FLAT structure for Figma Tokens Studio."""
3267
  if not state.desktop_normalized:
3268
  gr.Warning("No tokens extracted yet. Complete Stage 1 extraction first.")
 
3304
  primary_font = fonts_info.get("primary", "sans-serif")
3305
 
3306
  # =========================================================================
3307
+ # COLORS — Rule-based classification + optional ramps
3308
  # =========================================================================
3309
  if state.desktop_normalized and state.desktop_normalized.colors:
3310
  from core.color_utils import generate_color_ramp
3311
+ from core.color_classifier import classify_colors
3312
 
3313
+ classification = classify_colors(
3314
+ state.desktop_normalized.colors,
3315
+ convention=convention or "semantic",
3316
+ log_callback=state.log,
3317
  )
3318
 
3319
+ for c in classification.colors:
3320
+ flat_key = c.token_name
3321
  if apply_ramps:
3322
  try:
3323
+ ramp = generate_color_ramp(c.hex)
3324
  shades = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"]
3325
  for i, shade in enumerate(shades):
3326
  if i < len(ramp):
3327
  shade_key = f"{flat_key}.{shade}"
3328
+ color_val = ramp[i] if isinstance(ramp[i], str) else ramp[i].get("hex", c.hex)
3329
  dtcg_token = _to_dtcg_token(color_val, "color")
3330
  _flat_key_to_nested(shade_key, dtcg_token, result)
3331
  token_count += 1
3332
  except (ValueError, KeyError, TypeError, IndexError):
3333
+ dtcg_token = _to_dtcg_token(c.hex, "color")
3334
  _flat_key_to_nested(flat_key, dtcg_token, result)
3335
  token_count += 1
3336
  else:
3337
+ dtcg_token = _to_dtcg_token(c.hex, "color")
3338
  _flat_key_to_nested(flat_key, dtcg_token, result)
3339
  token_count += 1
3340
 
 
4527
  gr.Markdown("Export your finalized design tokens as JSON, compatible with **Figma Tokens Studio**.",
4528
  elem_classes=["section-desc"])
4529
  gr.Markdown("""
4530
+ - **Naming Convention:** Choose how colors are named in the export. Preview before exporting to verify.
4531
+ - **Stage 1 JSON (As-Is):** Raw extracted tokens useful for archival or baseline comparison.
4532
+ - **Final JSON (Upgraded):** Tokens with your selected improvements applied. **Recommended export.**
 
4533
  """, elem_classes=["section-desc"])
4534
 
4535
+ with gr.Row():
4536
+ naming_convention = gr.Dropdown(
4537
+ choices=["semantic", "tailwind", "material"],
4538
+ value="semantic",
4539
+ label="🎨 Naming Convention",
4540
+ info="semantic = color.brand.primary | tailwind = brand-primary | material = color.brand.primary",
4541
+ scale=2,
4542
+ )
4543
+ preview_colors_btn = gr.Button("👁️ Preview Color Names", variant="secondary", scale=1)
4544
+
4545
+ color_preview_output = gr.Code(
4546
+ label="Color Classification Preview (Rule-Based — No LLM)",
4547
+ language="text",
4548
+ lines=15,
4549
+ visible=True,
4550
+ )
4551
+
4552
  with gr.Row():
4553
  export_stage1_btn = gr.Button("📥 Export Stage 1 (As-Is)", variant="secondary")
4554
  export_final_btn = gr.Button("📥 Export Final (Upgraded)", variant="primary")
 
4557
  "Copy the contents or save as a `.json` file.*",
4558
  elem_classes=["section-desc"])
4559
  export_output = gr.Code(label="Tokens JSON", language="json", lines=25)
4560
+
4561
+ preview_colors_btn.click(
4562
+ preview_color_classification,
4563
+ inputs=[naming_convention],
4564
+ outputs=[color_preview_output],
4565
+ )
4566
+ export_stage1_btn.click(
4567
+ export_stage1_json,
4568
+ inputs=[naming_convention],
4569
+ outputs=[export_output],
4570
+ )
4571
+ export_final_btn.click(
4572
+ export_tokens_json,
4573
+ inputs=[naming_convention],
4574
+ outputs=[export_output],
4575
+ )
4576
 
4577
  # =================================================================
4578
  # EVENT HANDLERS
core/color_classifier.py ADDED
@@ -0,0 +1,678 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rule-Based Color Classifier
3
+ Design System Extractor v3.1
4
+
5
+ 100% deterministic color classification and naming.
6
+ NO LLM involved. Every decision logged with evidence.
7
+
8
+ The classifier:
9
+ 1. Aggressively deduplicates similar colors (same role, RGB distance < 30)
10
+ 2. Classifies by CSS evidence (property + element + hue)
11
+ 3. Names with capped counts per category (no "nonary" ever again)
12
+ 4. Supports user-selectable naming conventions (Tailwind, Material, Semantic, Custom)
13
+ 5. Logs every decision with evidence trail
14
+ """
15
+
16
+ import colorsys
17
+ from dataclasses import dataclass, field
18
+ from typing import Optional
19
+ from core.color_utils import parse_color, normalize_hex, categorize_color
20
+
21
+
22
+ # =============================================================================
23
+ # NAMING CONVENTIONS
24
+ # =============================================================================
25
+
26
+ CONVENTIONS = {
27
+ "tailwind": {
28
+ "separator": "-", # blue-500
29
+ "shade_format": "numeric", # 50, 100, 200...900
30
+ "prefix": "",
31
+ },
32
+ "material": {
33
+ "separator": ".", # blue.500
34
+ "shade_format": "numeric",
35
+ "prefix": "color.",
36
+ },
37
+ "semantic": {
38
+ "separator": ".", # color.brand.primary
39
+ "shade_format": "role", # primary, secondary, muted
40
+ "prefix": "color.",
41
+ },
42
+ }
43
+
44
+ # Role shade names for semantic convention
45
+ 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
+
53
+ # Feedback hue ranges (hue in degrees)
54
+ FEEDBACK_HUES = {
55
+ "error": (345, 15), # Red
56
+ "warning": (15, 55), # Orange/Yellow
57
+ "success": (85, 155), # Green
58
+ "info": (175, 215), # Cyan/Blue
59
+ }
60
+
61
+
62
+ # =============================================================================
63
+ # DATA CLASSES
64
+ # =============================================================================
65
+
66
+ @dataclass
67
+ class ClassifiedColor:
68
+ """A color with its classification and evidence."""
69
+ hex: str
70
+ frequency: int
71
+ category: str # brand, text, bg, border, feedback, palette
72
+ role: str # primary, secondary, etc. OR numeric shade
73
+ token_name: str # Final token name (e.g., "color.brand.primary")
74
+ evidence: list[str] = field(default_factory=list)
75
+ confidence: str = "medium" # high, medium, low
76
+ css_properties: list[str] = field(default_factory=list)
77
+ elements: list[str] = field(default_factory=list)
78
+ contexts: list[str] = field(default_factory=list)
79
+ merged_from: list[str] = field(default_factory=list) # Hex values merged into this
80
+ hue_family: str = "neutral"
81
+ luminance: float = 0.5
82
+ saturation: float = 0.0
83
+
84
+
85
+ @dataclass
86
+ class ClassificationResult:
87
+ """Complete classification output with log."""
88
+ colors: list[ClassifiedColor]
89
+ log: list[str] # Human-readable decision log
90
+ convention: str # Which convention was used
91
+ stats: dict = field(default_factory=dict)
92
+
93
+
94
+ # =============================================================================
95
+ # HELPER FUNCTIONS
96
+ # =============================================================================
97
+
98
+ def _rgb_distance(hex1: str, hex2: str) -> float:
99
+ """Euclidean distance in RGB space."""
100
+ p1 = parse_color(hex1)
101
+ p2 = parse_color(hex2)
102
+ if not p1 or not p2:
103
+ return 999
104
+ r1, g1, b1 = p1.rgb
105
+ r2, g2, b2 = p2.rgb
106
+ return ((r1-r2)**2 + (g1-g2)**2 + (b1-b2)**2) ** 0.5
107
+
108
+
109
+ def _get_luminance(hex_color: str) -> float:
110
+ """Get perceptual luminance 0-1."""
111
+ parsed = parse_color(hex_color)
112
+ if not parsed:
113
+ return 0.5
114
+ r, g, b = parsed.rgb
115
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255
116
+
117
+
118
+ def _get_saturation(hex_color: str) -> float:
119
+ """Get saturation 0-1."""
120
+ parsed = parse_color(hex_color)
121
+ if not parsed:
122
+ return 0
123
+ h, s, l = parsed.hsl
124
+ return s / 100 # hsl returns 0-100
125
+
126
+
127
+ def _get_hue(hex_color: str) -> float:
128
+ """Get hue in degrees 0-360."""
129
+ parsed = parse_color(hex_color)
130
+ if not parsed:
131
+ return 0
132
+ h, s, l = parsed.hsl
133
+ return h
134
+
135
+
136
+ def _hue_in_range(hue: float, range_tuple: tuple) -> bool:
137
+ """Check if hue is in range, handling wrap-around."""
138
+ low, high = range_tuple
139
+ if low < high:
140
+ return low <= hue <= high
141
+ else: # Wraps around 360 (e.g., red: 345-15)
142
+ return hue >= low or hue <= high
143
+
144
+
145
+ def _lightness_to_shade(lightness_pct: float) -> str:
146
+ """Map HSL lightness (0-100) to numeric shade (50-900)."""
147
+ if lightness_pct >= 95:
148
+ return "50"
149
+ elif lightness_pct >= 85:
150
+ return "100"
151
+ elif lightness_pct >= 75:
152
+ return "200"
153
+ elif lightness_pct >= 65:
154
+ return "300"
155
+ elif lightness_pct >= 55:
156
+ return "400"
157
+ elif lightness_pct >= 45:
158
+ return "500"
159
+ elif lightness_pct >= 35:
160
+ return "600"
161
+ elif lightness_pct >= 25:
162
+ return "700"
163
+ elif lightness_pct >= 15:
164
+ return "800"
165
+ else:
166
+ return "900"
167
+
168
+
169
+ # =============================================================================
170
+ # MAIN CLASSIFIER
171
+ # =============================================================================
172
+
173
+ def classify_colors(
174
+ colors_dict: dict,
175
+ convention: str = "semantic",
176
+ log_callback=None,
177
+ ) -> ClassificationResult:
178
+ """
179
+ Classify and name all colors using 100% rule-based logic.
180
+
181
+ Args:
182
+ colors_dict: Dict of name -> ColorToken (from normalizer)
183
+ convention: "tailwind", "material", or "semantic"
184
+ log_callback: Optional callback for real-time logging
185
+
186
+ Returns:
187
+ ClassificationResult with classified colors and decision log
188
+ """
189
+ log_lines = []
190
+
191
+ def log(msg):
192
+ log_lines.append(msg)
193
+ if log_callback:
194
+ log_callback(msg)
195
+
196
+ log("📊 COLOR CLASSIFICATION (Rule-Based v3.1)")
197
+ log("─" * 50)
198
+
199
+ if not colors_dict:
200
+ log("⚠️ No colors to classify")
201
+ return ClassificationResult(colors=[], log=log_lines, convention=convention)
202
+
203
+ # =========================================================================
204
+ # STEP 1: Build flat color list with metadata
205
+ # =========================================================================
206
+ raw_colors = []
207
+ for name, c in colors_dict.items():
208
+ hex_val = c.value if hasattr(c, 'value') else c.get('value', '')
209
+ hex_val = normalize_hex(hex_val)
210
+ freq = c.frequency if hasattr(c, 'frequency') else c.get('frequency', 0)
211
+ css_props = c.css_properties if hasattr(c, 'css_properties') else c.get('css_properties', [])
212
+ elements = c.elements if hasattr(c, 'elements') else c.get('elements', [])
213
+ contexts = c.contexts if hasattr(c, 'contexts') else c.get('contexts', [])
214
+ role_hint = c.role_hint if hasattr(c, 'role_hint') else c.get('role_hint', None)
215
+
216
+ raw_colors.append({
217
+ "hex": hex_val,
218
+ "frequency": freq,
219
+ "css_properties": [p.lower() for p in css_props],
220
+ "elements": [e.lower() for e in elements],
221
+ "contexts": [c.lower() for c in contexts],
222
+ "role_hint": role_hint,
223
+ "luminance": _get_luminance(hex_val),
224
+ "saturation": _get_saturation(hex_val),
225
+ "hue": _get_hue(hex_val),
226
+ "hue_family": categorize_color(hex_val),
227
+ })
228
+
229
+ log(f"Input: {len(raw_colors)} unique colors")
230
+
231
+ # =========================================================================
232
+ # STEP 2: Classify each color by CSS evidence
233
+ # =========================================================================
234
+ classified = []
235
+ for c in raw_colors:
236
+ category = _classify_single_color(c)
237
+ c["category"] = category
238
+ classified.append(c)
239
+
240
+ # Count per category
241
+ cat_counts = {}
242
+ for c in classified:
243
+ cat_counts[c["category"]] = cat_counts.get(c["category"], 0) + 1
244
+ log(f"Pre-dedup classification: {cat_counts}")
245
+
246
+ # =========================================================================
247
+ # STEP 3: Aggressive dedup WITHIN each category
248
+ # =========================================================================
249
+ deduped = _aggressive_dedup(classified, log)
250
+
251
+ cat_counts_after = {}
252
+ for c in deduped:
253
+ cat_counts_after[c["category"]] = cat_counts_after.get(c["category"], 0) + 1
254
+ log(f"Post-dedup: {len(deduped)} colors — {cat_counts_after}")
255
+
256
+ # =========================================================================
257
+ # STEP 4: Cap and rank within each category
258
+ # =========================================================================
259
+ capped = _cap_per_category(deduped, log)
260
+
261
+ # =========================================================================
262
+ # STEP 5: Assign final names using chosen convention
263
+ # =========================================================================
264
+ result_colors = _assign_names(capped, convention, log)
265
+
266
+ log(f"─" * 50)
267
+ log(f"✅ Final: {len(result_colors)} color tokens using '{convention}' convention")
268
+
269
+ stats = {
270
+ "input_count": len(raw_colors),
271
+ "output_count": len(result_colors),
272
+ "merged_count": len(raw_colors) - len(deduped),
273
+ "categories": cat_counts_after,
274
+ }
275
+
276
+ return ClassificationResult(
277
+ colors=result_colors,
278
+ log=log_lines,
279
+ convention=convention,
280
+ stats=stats,
281
+ )
282
+
283
+
284
+ # =============================================================================
285
+ # STEP 2: CLASSIFY SINGLE COLOR
286
+ # =============================================================================
287
+
288
+ def _classify_single_color(c: dict) -> str:
289
+ """
290
+ Classify a single color into category based on CSS evidence.
291
+ Returns: "brand", "text", "bg", "border", "feedback", "palette"
292
+ """
293
+ css_props = c["css_properties"]
294
+ elements = c["elements"]
295
+ all_context = " ".join(css_props + elements + c["contexts"])
296
+ sat = c["saturation"]
297
+ lum = c["luminance"]
298
+ hue = c["hue"]
299
+ freq = c["frequency"]
300
+
301
+ # --- FEEDBACK: by hue + saturation (check FIRST, before brand) ---
302
+ if sat > 0.4:
303
+ # Check explicit feedback keywords in context
304
+ feedback_keywords = ["error", "danger", "invalid", "success", "valid",
305
+ "warning", "caution", "alert", "info", "notice"]
306
+ if any(kw in all_context for kw in feedback_keywords):
307
+ return "feedback"
308
+
309
+ # Check by hue if frequency is low (feedback colors are rarely used often)
310
+ if freq < 15:
311
+ for fb_type, hue_range in FEEDBACK_HUES.items():
312
+ if _hue_in_range(hue, hue_range):
313
+ # Additional check: pure saturated colors with low freq = likely feedback
314
+ if sat > 0.6:
315
+ return "feedback"
316
+
317
+ # --- BRAND: interactive elements + background-color + saturated ---
318
+ interactive = ["button", "a", "input", "select", "submit", "btn", "cta", "link"]
319
+ is_interactive = any(el in all_context for el in interactive)
320
+ has_bg_prop = any("background" in p for p in css_props)
321
+
322
+ if sat > 0.25 and is_interactive and has_bg_prop and freq > 5:
323
+ return "brand"
324
+ if sat > 0.3 and freq > 20:
325
+ return "brand"
326
+
327
+ # --- TEXT: "color" property on text elements + low saturation ---
328
+ has_color_prop = any(p == "color" for p in css_props)
329
+ text_els = ["p", "span", "h1", "h2", "h3", "h4", "h5", "h6", "label", "li", "td", "a"]
330
+ is_text_el = any(el in all_context for el in text_els)
331
+
332
+ if has_color_prop and (is_text_el or sat < 0.15):
333
+ if sat < 0.2:
334
+ return "text"
335
+
336
+ # --- BACKGROUND: background-color on containers + light/neutral ---
337
+ containers = ["div", "section", "main", "body", "article", "header", "footer", "card", "nav"]
338
+ is_container = any(el in all_context for el in containers)
339
+
340
+ if has_bg_prop and is_container and sat < 0.15:
341
+ return "bg"
342
+ if lum > 0.9 and sat < 0.1:
343
+ return "bg"
344
+
345
+ # --- BORDER: border properties ---
346
+ has_border_prop = any("border" in p for p in css_props)
347
+ if has_border_prop and sat < 0.2:
348
+ return "border"
349
+
350
+ # --- Neutral with high freq but no clear CSS evidence → likely text ---
351
+ if sat < 0.1 and lum < 0.6 and freq > 10:
352
+ return "text"
353
+ if sat < 0.1 and lum > 0.8:
354
+ return "bg"
355
+
356
+ # --- Everything else → palette ---
357
+ return "palette"
358
+
359
+
360
+ # =============================================================================
361
+ # STEP 3: AGGRESSIVE DEDUP
362
+ # =============================================================================
363
+
364
+ def _aggressive_dedup(colors: list[dict], log) -> list[dict]:
365
+ """
366
+ Aggressively merge similar colors WITHIN the same category.
367
+ Threshold: RGB distance < 30 for same-category colors.
368
+ """
369
+ # Group by category
370
+ by_category = {}
371
+ for c in colors:
372
+ cat = c["category"]
373
+ if cat not in by_category:
374
+ by_category[cat] = []
375
+ by_category[cat].append(c)
376
+
377
+ result = []
378
+ total_merged = 0
379
+
380
+ for cat, cat_colors in by_category.items():
381
+ if len(cat_colors) <= 1:
382
+ result.extend(cat_colors)
383
+ continue
384
+
385
+ # Sort by frequency (most used first — these survive merges)
386
+ cat_colors.sort(key=lambda x: -x["frequency"])
387
+
388
+ merged = []
389
+ used = set()
390
+
391
+ for i, c1 in enumerate(cat_colors):
392
+ if i in used:
393
+ continue
394
+
395
+ group = [c1]
396
+ for j, c2 in enumerate(cat_colors[i+1:], i+1):
397
+ if j in used:
398
+ continue
399
+ dist = _rgb_distance(c1["hex"], c2["hex"])
400
+ if dist < 30:
401
+ group.append(c2)
402
+ used.add(j)
403
+
404
+ # Merge into the highest-frequency color
405
+ primary = group[0]
406
+ merged_hexes = []
407
+ for other in group[1:]:
408
+ primary["frequency"] += other["frequency"]
409
+ primary["css_properties"] = list(set(primary["css_properties"] + other["css_properties"]))
410
+ primary["elements"] = list(set(primary["elements"] + other["elements"]))
411
+ primary["contexts"] = list(set(primary["contexts"] + other["contexts"]))
412
+ merged_hexes.append(other["hex"])
413
+
414
+ primary["merged_from"] = merged_hexes
415
+ merged.append(primary)
416
+ used.add(i)
417
+
418
+ if merged_hexes:
419
+ total_merged += len(merged_hexes)
420
+ log(f"[DEDUP] {cat}: {primary['hex']} absorbed {merged_hexes} (dist<30)")
421
+
422
+ result.extend(merged)
423
+
424
+ if total_merged > 0:
425
+ log(f"[DEDUP] Total: {total_merged} near-duplicate colors merged")
426
+
427
+ return result
428
+
429
+
430
+ # =============================================================================
431
+ # STEP 4: CAP PER CATEGORY
432
+ # =============================================================================
433
+
434
+ # Maximum colors per category
435
+ CATEGORY_CAPS = {
436
+ "brand": 3, # primary, secondary, accent
437
+ "text": 3, # primary, secondary, muted
438
+ "bg": 3, # primary, secondary, tertiary
439
+ "border": 3, # light, default, dark
440
+ "feedback": 4, # error, warning, success, info
441
+ "palette": 20, # generous cap for remaining
442
+ }
443
+
444
+
445
+ def _cap_per_category(colors: list[dict], log) -> list[dict]:
446
+ """
447
+ Limit colors per category. Excess become palette colors.
448
+ Within each category, keep by frequency (most used survives).
449
+ """
450
+ by_category = {}
451
+ for c in colors:
452
+ cat = c["category"]
453
+ if cat not in by_category:
454
+ by_category[cat] = []
455
+ by_category[cat].append(c)
456
+
457
+ result = []
458
+
459
+ for cat, cat_colors in by_category.items():
460
+ cap = CATEGORY_CAPS.get(cat, 10)
461
+ cat_colors.sort(key=lambda x: -x["frequency"])
462
+
463
+ kept = cat_colors[:cap]
464
+ overflow = cat_colors[cap:]
465
+
466
+ result.extend(kept)
467
+
468
+ # Overflow colors become palette
469
+ for c in overflow:
470
+ old_cat = c["category"]
471
+ c["category"] = "palette"
472
+ log(f"[CAP] {c['hex']} demoted: {old_cat} → palette (category full, freq={c['frequency']})")
473
+
474
+ result.extend(overflow)
475
+
476
+ return result
477
+
478
+
479
+ # =============================================================================
480
+ # STEP 5: ASSIGN NAMES
481
+ # =============================================================================
482
+
483
+ def _assign_names(colors: list[dict], convention: str, log) -> list[ClassifiedColor]:
484
+ """
485
+ Assign final token names based on chosen convention.
486
+ """
487
+ conv = CONVENTIONS.get(convention, CONVENTIONS["semantic"])
488
+ prefix = conv["prefix"]
489
+ sep = conv["separator"]
490
+
491
+ # Group by category for role assignment
492
+ by_category = {}
493
+ for c in colors:
494
+ cat = c["category"]
495
+ if cat not in by_category:
496
+ by_category[cat] = []
497
+ by_category[cat].append(c)
498
+
499
+ result = []
500
+ used_names = set()
501
+
502
+ for cat in ["brand", "text", "bg", "border", "feedback", "palette"]:
503
+ cat_colors = by_category.get(cat, [])
504
+ if not cat_colors:
505
+ continue
506
+
507
+ # Sort by frequency for consistent ordering
508
+ cat_colors.sort(key=lambda x: -x["frequency"])
509
+
510
+ for idx, c in enumerate(cat_colors):
511
+ name_cat = cat # Local var — don't override loop variable
512
+
513
+ if cat == "feedback":
514
+ role = _assign_feedback_role(c, idx, by_category.get("feedback", []))
515
+ elif cat == "palette":
516
+ # Palette: use hue family + numeric shade (ALWAYS)
517
+ name_cat = c["hue_family"] # Override with hue family
518
+ parsed = parse_color(c["hex"])
519
+ if parsed:
520
+ role = _lightness_to_shade(parsed.hsl[2])
521
+ else:
522
+ role = "500"
523
+ elif convention == "semantic":
524
+ # Semantic: use role names (primary, secondary, muted, etc.)
525
+ role_names = ROLE_SHADE_NAMES.get(c["category"], ["primary", "secondary", "tertiary"])
526
+ if idx < len(role_names):
527
+ role = role_names[idx]
528
+ else:
529
+ role = f"{idx + 1}"
530
+ else:
531
+ # Tailwind/Material: even role colors get descriptive names
532
+ role_names = ROLE_SHADE_NAMES.get(c["category"], ["primary", "secondary", "tertiary"])
533
+ if idx < len(role_names):
534
+ role = role_names[idx]
535
+ else:
536
+ role = f"{idx + 1}"
537
+
538
+ # Build token name
539
+ if convention == "tailwind":
540
+ token_name = f"{name_cat}{sep}{role}"
541
+ else:
542
+ token_name = f"{prefix}{name_cat}{sep}{role}"
543
+
544
+ # Handle name collisions
545
+ base_name = token_name
546
+ suffix = 2
547
+ while token_name in used_names:
548
+ token_name = f"{base_name}{sep}{suffix}"
549
+ suffix += 1
550
+ used_names.add(token_name)
551
+
552
+ # Build evidence
553
+ evidence = _build_evidence(c)
554
+
555
+ log(f"[NAME] {c['hex']} → {token_name} ({c['category']}, freq={c['frequency']})")
556
+
557
+ result.append(ClassifiedColor(
558
+ hex=c["hex"],
559
+ frequency=c["frequency"],
560
+ category=c["category"],
561
+ role=role,
562
+ token_name=token_name,
563
+ evidence=evidence,
564
+ confidence="high" if c["frequency"] > 10 else "medium" if c["frequency"] > 3 else "low",
565
+ css_properties=c["css_properties"],
566
+ elements=c["elements"],
567
+ contexts=c["contexts"],
568
+ merged_from=c.get("merged_from", []),
569
+ hue_family=c["hue_family"],
570
+ luminance=c["luminance"],
571
+ saturation=c["saturation"],
572
+ ))
573
+
574
+ return result
575
+
576
+
577
+ def _assign_feedback_role(c: dict, idx: int, all_feedback: list) -> str:
578
+ """Assign feedback role by hue matching."""
579
+ hue = c["hue"]
580
+ sat = c["saturation"]
581
+
582
+ # Try to match by hue
583
+ if _hue_in_range(hue, FEEDBACK_HUES["error"]) and sat > 0.4:
584
+ return "error"
585
+ if _hue_in_range(hue, FEEDBACK_HUES["warning"]) and sat > 0.4:
586
+ return "warning"
587
+ if _hue_in_range(hue, FEEDBACK_HUES["success"]) and sat > 0.4:
588
+ return "success"
589
+ if _hue_in_range(hue, FEEDBACK_HUES["info"]) and sat > 0.3:
590
+ return "info"
591
+
592
+ # Fallback: by index
593
+ fallback_names = ["error", "warning", "success", "info"]
594
+ return fallback_names[idx % len(fallback_names)]
595
+
596
+
597
+ def _build_evidence(c: dict) -> list[str]:
598
+ """Build human-readable evidence list for classification."""
599
+ ev = []
600
+ if c["frequency"] > 0:
601
+ ev.append(f"Used {c['frequency']}x")
602
+ if c["css_properties"]:
603
+ ev.append(f"CSS: {', '.join(c['css_properties'][:3])}")
604
+ if c["elements"]:
605
+ ev.append(f"Elements: {', '.join(c['elements'][:3])}")
606
+ if c.get("merged_from"):
607
+ ev.append(f"Merged {len(c['merged_from'])} similar colors")
608
+ ev.append(f"Hue: {c['hue_family']}, Lum: {c['luminance']:.2f}, Sat: {c['saturation']:.2f}")
609
+ return ev
610
+
611
+
612
+ # =============================================================================
613
+ # PREVIEW GENERATOR
614
+ # =============================================================================
615
+
616
+ def generate_classification_preview(result: ClassificationResult) -> str:
617
+ """
618
+ Generate a human-readable preview of the classification.
619
+ Shows what the user will get before export.
620
+ """
621
+ lines = []
622
+ lines.append(f"🎨 COLOR CLASSIFICATION PREVIEW ({result.convention} convention)")
623
+ lines.append(f"{'=' * 60}")
624
+ lines.append("")
625
+
626
+ # Group by category
627
+ by_cat = {}
628
+ for c in result.colors:
629
+ if c.category not in by_cat:
630
+ by_cat[c.category] = []
631
+ by_cat[c.category].append(c)
632
+
633
+ cat_labels = {
634
+ "brand": "🏷️ BRAND (from buttons/CTAs)",
635
+ "text": "📝 TEXT (from color property)",
636
+ "bg": "🖼️ BACKGROUND (from containers)",
637
+ "border": "📐 BORDER (from border properties)",
638
+ "feedback": "⚡ FEEDBACK (by hue convention)",
639
+ }
640
+
641
+ for cat in ["brand", "text", "bg", "border", "feedback"]:
642
+ cat_colors = by_cat.get(cat, [])
643
+ if not cat_colors:
644
+ continue
645
+
646
+ label = cat_labels.get(cat, f"📦 {cat.upper()}")
647
+ lines.append(label)
648
+
649
+ for c in cat_colors:
650
+ ev_short = f"({c.frequency}x" + (f", merged {len(c.merged_from)}" if c.merged_from else "") + ")"
651
+ lines.append(f" ■ {c.hex} → {c.token_name} {ev_short}")
652
+
653
+ lines.append("")
654
+
655
+ # Palette colors (group by hue family)
656
+ palette = by_cat.get("palette", [])
657
+ if palette:
658
+ lines.append("🎨 PALETTE (remaining by hue)")
659
+ hue_groups = {}
660
+ for c in palette:
661
+ if c.hue_family not in hue_groups:
662
+ hue_groups[c.hue_family] = []
663
+ hue_groups[c.hue_family].append(c)
664
+
665
+ for hue_fam, hue_colors in sorted(hue_groups.items()):
666
+ for c in hue_colors:
667
+ lines.append(f" ■ {c.hex} → {c.token_name} ({c.frequency}x)")
668
+
669
+ lines.append("")
670
+
671
+ # Stats
672
+ lines.append(f"{'─' * 60}")
673
+ if result.stats:
674
+ lines.append(f"Total: {result.stats.get('output_count', 0)} tokens")
675
+ if result.stats.get("merged_count", 0) > 0:
676
+ lines.append(f"⚠️ {result.stats['merged_count']} near-duplicate colors were merged")
677
+
678
+ return "\n".join(lines)
output_json/file (18).json ADDED
@@ -0,0 +1,584 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "color": {
3
+ "text": {
4
+ "primary": {
5
+ "$type": "color",
6
+ "$value": "#373737"
7
+ },
8
+ "secondary": {
9
+ "$type": "color",
10
+ "$value": "#000000"
11
+ },
12
+ "tertiary": {
13
+ "$type": "color",
14
+ "$value": "#999999"
15
+ },
16
+ "quaternary": {
17
+ "$type": "color",
18
+ "$value": "#4e4c4a"
19
+ },
20
+ "quinary": {
21
+ "$type": "color",
22
+ "$value": "#808080"
23
+ },
24
+ "senary": {
25
+ "$type": "color",
26
+ "$value": "#cccccc"
27
+ },
28
+ "septenary": {
29
+ "$type": "color",
30
+ "$value": "#404040"
31
+ },
32
+ "octonary": {
33
+ "$type": "color",
34
+ "$value": "#727272"
35
+ },
36
+ "nonary": {
37
+ "$type": "color",
38
+ "$value": "#aaaaaa"
39
+ },
40
+ "decenary": {
41
+ "$type": "color",
42
+ "$value": "#656565"
43
+ },
44
+ "undecenary": {
45
+ "$type": "color",
46
+ "$value": "#0e0c24"
47
+ },
48
+ "duodecenary": {
49
+ "$type": "color",
50
+ "$value": "#282828"
51
+ },
52
+ "tredecenary": {
53
+ "$type": "color",
54
+ "$value": "#151414"
55
+ }
56
+ },
57
+ "bg": {
58
+ "primary": {
59
+ "$type": "color",
60
+ "$value": "#ffffff"
61
+ },
62
+ "light": {
63
+ "$type": "color",
64
+ "$value": "#f6f6f6"
65
+ },
66
+ "medium": {
67
+ "$type": "color",
68
+ "$value": "#ecedee"
69
+ },
70
+ "error": {
71
+ "$type": "color",
72
+ "$value": "#fff2f2"
73
+ }
74
+ },
75
+ "border": {
76
+ "light": {
77
+ "$type": "color",
78
+ "$value": "#d3d3d3"
79
+ },
80
+ "medium": {
81
+ "$type": "color",
82
+ "$value": "#e4e4e4"
83
+ },
84
+ "dark": {
85
+ "$type": "color",
86
+ "$value": "#b3b3b3"
87
+ },
88
+ "heavy": {
89
+ "$type": "color",
90
+ "$value": "#2c3e50"
91
+ }
92
+ },
93
+ "brand": {
94
+ "primary": {
95
+ "$type": "color",
96
+ "$value": "#06b2c4"
97
+ },
98
+ "secondary": {
99
+ "$type": "color",
100
+ "$value": "#bcd432"
101
+ },
102
+ "accent": {
103
+ "$type": "color",
104
+ "$value": "#ff1857"
105
+ },
106
+ "error": {
107
+ "$type": "color",
108
+ "$value": "#f20000"
109
+ },
110
+ "info": {
111
+ "$type": "color",
112
+ "$value": "#33cccc"
113
+ },
114
+ "warning": {
115
+ "$type": "color",
116
+ "$value": "#ff8f00"
117
+ },
118
+ "success": {
119
+ "$type": "color",
120
+ "$value": "#65a121"
121
+ }
122
+ },
123
+ "#333333": {
124
+ "$type": "color",
125
+ "$value": "#333333"
126
+ },
127
+ "neutral": {
128
+ "400": {
129
+ "$type": "color",
130
+ "$value": "#78808e"
131
+ }
132
+ }
133
+ },
134
+ "font": {
135
+ "display": {
136
+ "2xl": {
137
+ "desktop": {
138
+ "$type": "typography",
139
+ "$value": {
140
+ "fontFamily": "Open Sans",
141
+ "fontSize": "68px",
142
+ "fontWeight": "700",
143
+ "lineHeight": "1.2"
144
+ }
145
+ },
146
+ "mobile": {
147
+ "$type": "typography",
148
+ "$value": {
149
+ "fontFamily": "Open Sans",
150
+ "fontSize": "60px",
151
+ "fontWeight": "700",
152
+ "lineHeight": "1.2"
153
+ }
154
+ }
155
+ },
156
+ "xl": {
157
+ "desktop": {
158
+ "$type": "typography",
159
+ "$value": {
160
+ "fontFamily": "Open Sans",
161
+ "fontSize": "58px",
162
+ "fontWeight": "700",
163
+ "lineHeight": "1.2"
164
+ }
165
+ },
166
+ "mobile": {
167
+ "$type": "typography",
168
+ "$value": {
169
+ "fontFamily": "Open Sans",
170
+ "fontSize": "50px",
171
+ "fontWeight": "700",
172
+ "lineHeight": "1.2"
173
+ }
174
+ }
175
+ },
176
+ "lg": {
177
+ "desktop": {
178
+ "$type": "typography",
179
+ "$value": {
180
+ "fontFamily": "Open Sans",
181
+ "fontSize": "48px",
182
+ "fontWeight": "700",
183
+ "lineHeight": "1.2"
184
+ }
185
+ },
186
+ "mobile": {
187
+ "$type": "typography",
188
+ "$value": {
189
+ "fontFamily": "Open Sans",
190
+ "fontSize": "42px",
191
+ "fontWeight": "700",
192
+ "lineHeight": "1.2"
193
+ }
194
+ }
195
+ },
196
+ "md": {
197
+ "desktop": {
198
+ "$type": "typography",
199
+ "$value": {
200
+ "fontFamily": "Open Sans",
201
+ "fontSize": "40px",
202
+ "fontWeight": "700",
203
+ "lineHeight": "1.2"
204
+ }
205
+ },
206
+ "mobile": {
207
+ "$type": "typography",
208
+ "$value": {
209
+ "fontFamily": "Open Sans",
210
+ "fontSize": "34px",
211
+ "fontWeight": "700",
212
+ "lineHeight": "1.2"
213
+ }
214
+ }
215
+ }
216
+ },
217
+ "heading": {
218
+ "xl": {
219
+ "desktop": {
220
+ "$type": "typography",
221
+ "$value": {
222
+ "fontFamily": "Open Sans",
223
+ "fontSize": "34px",
224
+ "fontWeight": "600",
225
+ "lineHeight": "1.3"
226
+ }
227
+ },
228
+ "mobile": {
229
+ "$type": "typography",
230
+ "$value": {
231
+ "fontFamily": "Open Sans",
232
+ "fontSize": "30px",
233
+ "fontWeight": "600",
234
+ "lineHeight": "1.3"
235
+ }
236
+ }
237
+ },
238
+ "lg": {
239
+ "desktop": {
240
+ "$type": "typography",
241
+ "$value": {
242
+ "fontFamily": "Open Sans",
243
+ "fontSize": "28px",
244
+ "fontWeight": "600",
245
+ "lineHeight": "1.3"
246
+ }
247
+ },
248
+ "mobile": {
249
+ "$type": "typography",
250
+ "$value": {
251
+ "fontFamily": "Open Sans",
252
+ "fontSize": "24px",
253
+ "fontWeight": "600",
254
+ "lineHeight": "1.3"
255
+ }
256
+ }
257
+ },
258
+ "md": {
259
+ "desktop": {
260
+ "$type": "typography",
261
+ "$value": {
262
+ "fontFamily": "Open Sans",
263
+ "fontSize": "24px",
264
+ "fontWeight": "600",
265
+ "lineHeight": "1.3"
266
+ }
267
+ },
268
+ "mobile": {
269
+ "$type": "typography",
270
+ "$value": {
271
+ "fontFamily": "Open Sans",
272
+ "fontSize": "20px",
273
+ "fontWeight": "600",
274
+ "lineHeight": "1.3"
275
+ }
276
+ }
277
+ },
278
+ "sm": {
279
+ "desktop": {
280
+ "$type": "typography",
281
+ "$value": {
282
+ "fontFamily": "Open Sans",
283
+ "fontSize": "20px",
284
+ "fontWeight": "600",
285
+ "lineHeight": "1.3"
286
+ }
287
+ },
288
+ "mobile": {
289
+ "$type": "typography",
290
+ "$value": {
291
+ "fontFamily": "Open Sans",
292
+ "fontSize": "16px",
293
+ "fontWeight": "600",
294
+ "lineHeight": "1.3"
295
+ }
296
+ }
297
+ }
298
+ },
299
+ "body": {
300
+ "lg": {
301
+ "desktop": {
302
+ "$type": "typography",
303
+ "$value": {
304
+ "fontFamily": "Open Sans",
305
+ "fontSize": "16px",
306
+ "fontWeight": "400",
307
+ "lineHeight": "1.5"
308
+ }
309
+ },
310
+ "mobile": {
311
+ "$type": "typography",
312
+ "$value": {
313
+ "fontFamily": "Open Sans",
314
+ "fontSize": "14px",
315
+ "fontWeight": "400",
316
+ "lineHeight": "1.5"
317
+ }
318
+ }
319
+ },
320
+ "md": {
321
+ "desktop": {
322
+ "$type": "typography",
323
+ "$value": {
324
+ "fontFamily": "Open Sans",
325
+ "fontSize": "14px",
326
+ "fontWeight": "400",
327
+ "lineHeight": "1.5"
328
+ }
329
+ },
330
+ "mobile": {
331
+ "$type": "typography",
332
+ "$value": {
333
+ "fontFamily": "Open Sans",
334
+ "fontSize": "12px",
335
+ "fontWeight": "400",
336
+ "lineHeight": "1.5"
337
+ }
338
+ }
339
+ },
340
+ "sm": {
341
+ "desktop": {
342
+ "$type": "typography",
343
+ "$value": {
344
+ "fontFamily": "Open Sans",
345
+ "fontSize": "12px",
346
+ "fontWeight": "400",
347
+ "lineHeight": "1.5"
348
+ }
349
+ },
350
+ "mobile": {
351
+ "$type": "typography",
352
+ "$value": {
353
+ "fontFamily": "Open Sans",
354
+ "fontSize": "10px",
355
+ "fontWeight": "400",
356
+ "lineHeight": "1.5"
357
+ }
358
+ }
359
+ }
360
+ },
361
+ "caption": {
362
+ "desktop": {
363
+ "$type": "typography",
364
+ "$value": {
365
+ "fontFamily": "Open Sans",
366
+ "fontSize": "10px",
367
+ "fontWeight": "400",
368
+ "lineHeight": "1.4"
369
+ }
370
+ },
371
+ "mobile": {
372
+ "$type": "typography",
373
+ "$value": {
374
+ "fontFamily": "Open Sans",
375
+ "fontSize": "8px",
376
+ "fontWeight": "400",
377
+ "lineHeight": "1.4"
378
+ }
379
+ }
380
+ },
381
+ "overline": {
382
+ "desktop": {
383
+ "$type": "typography",
384
+ "$value": {
385
+ "fontFamily": "Open Sans",
386
+ "fontSize": "8px",
387
+ "fontWeight": "500",
388
+ "lineHeight": "1.2"
389
+ }
390
+ },
391
+ "mobile": {
392
+ "$type": "typography",
393
+ "$value": {
394
+ "fontFamily": "Open Sans",
395
+ "fontSize": "6px",
396
+ "fontWeight": "500",
397
+ "lineHeight": "1.2"
398
+ }
399
+ }
400
+ }
401
+ },
402
+ "space": {
403
+ "1": {
404
+ "desktop": {
405
+ "$type": "dimension",
406
+ "$value": "8px"
407
+ },
408
+ "mobile": {
409
+ "$type": "dimension",
410
+ "$value": "8px"
411
+ }
412
+ },
413
+ "2": {
414
+ "desktop": {
415
+ "$type": "dimension",
416
+ "$value": "16px"
417
+ },
418
+ "mobile": {
419
+ "$type": "dimension",
420
+ "$value": "16px"
421
+ }
422
+ },
423
+ "3": {
424
+ "desktop": {
425
+ "$type": "dimension",
426
+ "$value": "24px"
427
+ },
428
+ "mobile": {
429
+ "$type": "dimension",
430
+ "$value": "24px"
431
+ }
432
+ },
433
+ "4": {
434
+ "desktop": {
435
+ "$type": "dimension",
436
+ "$value": "32px"
437
+ },
438
+ "mobile": {
439
+ "$type": "dimension",
440
+ "$value": "32px"
441
+ }
442
+ },
443
+ "5": {
444
+ "desktop": {
445
+ "$type": "dimension",
446
+ "$value": "40px"
447
+ },
448
+ "mobile": {
449
+ "$type": "dimension",
450
+ "$value": "40px"
451
+ }
452
+ },
453
+ "6": {
454
+ "desktop": {
455
+ "$type": "dimension",
456
+ "$value": "48px"
457
+ },
458
+ "mobile": {
459
+ "$type": "dimension",
460
+ "$value": "48px"
461
+ }
462
+ },
463
+ "8": {
464
+ "desktop": {
465
+ "$type": "dimension",
466
+ "$value": "56px"
467
+ },
468
+ "mobile": {
469
+ "$type": "dimension",
470
+ "$value": "56px"
471
+ }
472
+ },
473
+ "10": {
474
+ "desktop": {
475
+ "$type": "dimension",
476
+ "$value": "64px"
477
+ },
478
+ "mobile": {
479
+ "$type": "dimension",
480
+ "$value": "64px"
481
+ }
482
+ },
483
+ "12": {
484
+ "desktop": {
485
+ "$type": "dimension",
486
+ "$value": "72px"
487
+ },
488
+ "mobile": {
489
+ "$type": "dimension",
490
+ "$value": "72px"
491
+ }
492
+ },
493
+ "16": {
494
+ "desktop": {
495
+ "$type": "dimension",
496
+ "$value": "80px"
497
+ },
498
+ "mobile": {
499
+ "$type": "dimension",
500
+ "$value": "80px"
501
+ }
502
+ }
503
+ },
504
+ "radius": {
505
+ "xs": {
506
+ "$type": "dimension",
507
+ "$value": "1px"
508
+ },
509
+ "sm": {
510
+ "$type": "dimension",
511
+ "$value": "2px",
512
+ "3": {
513
+ "$type": "dimension",
514
+ "$value": "3px"
515
+ }
516
+ },
517
+ "md": {
518
+ "$type": "dimension",
519
+ "$value": "4px",
520
+ "5": {
521
+ "$type": "dimension",
522
+ "$value": "5px"
523
+ },
524
+ "6": {
525
+ "$type": "dimension",
526
+ "$value": "6px"
527
+ },
528
+ "100": {
529
+ "$type": "dimension",
530
+ "$value": "100px"
531
+ }
532
+ },
533
+ "lg": {
534
+ "$type": "dimension",
535
+ "$value": "8px",
536
+ "10": {
537
+ "$type": "dimension",
538
+ "$value": "10px"
539
+ }
540
+ },
541
+ "xl": {
542
+ "$type": "dimension",
543
+ "$value": "16px",
544
+ "17": {
545
+ "$type": "dimension",
546
+ "$value": "17px"
547
+ }
548
+ },
549
+ "2xl": {
550
+ "$type": "dimension",
551
+ "$value": "20px"
552
+ },
553
+ "3xl": {
554
+ "$type": "dimension",
555
+ "$value": "50px"
556
+ },
557
+ "full": {
558
+ "$type": "dimension",
559
+ "$value": "9999px"
560
+ }
561
+ },
562
+ "shadow": {
563
+ "xs": {
564
+ "$type": "shadow",
565
+ "$value": {
566
+ "color": "rgba(0, 0, 0, 0.5)",
567
+ "offsetX": "0px",
568
+ "offsetY": "2px",
569
+ "blur": "4px",
570
+ "spread": "0px"
571
+ }
572
+ },
573
+ "sm": {
574
+ "$type": "shadow",
575
+ "$value": {
576
+ "color": "rgba(0, 0, 0, 0.15)",
577
+ "offsetX": "0px",
578
+ "offsetY": "0px",
579
+ "blur": "16px",
580
+ "spread": "0px"
581
+ }
582
+ }
583
+ }
584
+ }