riazmo commited on
Commit
7edec80
·
verified ·
1 Parent(s): 2a51260

Upload 2 files

Browse files
Files changed (2) hide show
  1. core/hf_inference.py +602 -0
  2. core/preview_generator.py +1534 -0
core/hf_inference.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HuggingFace Inference Client
3
+ Design System Extractor v2
4
+
5
+ Handles all LLM inference calls using HuggingFace Inference API.
6
+ Supports diverse models from different providers for specialized tasks.
7
+ """
8
+
9
+ import os
10
+ from typing import Optional, AsyncGenerator
11
+ from dataclasses import dataclass
12
+ from huggingface_hub import InferenceClient, AsyncInferenceClient
13
+
14
+ from config.settings import get_settings
15
+
16
+
17
+ @dataclass
18
+ class ModelInfo:
19
+ """Information about a model."""
20
+ model_id: str
21
+ provider: str
22
+ context_length: int
23
+ strengths: list[str]
24
+ best_for: str
25
+ tier: str # "free", "pro", "pro+"
26
+
27
+
28
+ # =============================================================================
29
+ # COMPREHENSIVE MODEL REGISTRY — Organized by Provider
30
+ # =============================================================================
31
+
32
+ AVAILABLE_MODELS = {
33
+ # =========================================================================
34
+ # META — Llama Family (Best for reasoning)
35
+ # =========================================================================
36
+ "meta-llama/Llama-3.1-405B-Instruct": ModelInfo(
37
+ model_id="meta-llama/Llama-3.1-405B-Instruct",
38
+ provider="Meta",
39
+ context_length=128000,
40
+ strengths=["Best reasoning", "Massive knowledge", "Complex analysis"],
41
+ best_for="Agent 3 (Advisor) — PREMIUM CHOICE",
42
+ tier="pro+"
43
+ ),
44
+ "meta-llama/Llama-3.1-70B-Instruct": ModelInfo(
45
+ model_id="meta-llama/Llama-3.1-70B-Instruct",
46
+ provider="Meta",
47
+ context_length=128000,
48
+ strengths=["Excellent reasoning", "Long context", "Design knowledge"],
49
+ best_for="Agent 3 (Advisor) — RECOMMENDED",
50
+ tier="pro"
51
+ ),
52
+ "meta-llama/Llama-3.1-8B-Instruct": ModelInfo(
53
+ model_id="meta-llama/Llama-3.1-8B-Instruct",
54
+ provider="Meta",
55
+ context_length=128000,
56
+ strengths=["Fast", "Good reasoning for size", "Long context"],
57
+ best_for="Budget Agent 3 fallback",
58
+ tier="free"
59
+ ),
60
+
61
+ # =========================================================================
62
+ # MISTRAL — European Excellence
63
+ # =========================================================================
64
+ "mistralai/Mixtral-8x22B-Instruct-v0.1": ModelInfo(
65
+ model_id="mistralai/Mixtral-8x22B-Instruct-v0.1",
66
+ provider="Mistral",
67
+ context_length=65536,
68
+ strengths=["Large MoE", "Strong reasoning", "Efficient"],
69
+ best_for="Agent 3 (Advisor) — Pro alternative",
70
+ tier="pro"
71
+ ),
72
+ "mistralai/Mixtral-8x7B-Instruct-v0.1": ModelInfo(
73
+ model_id="mistralai/Mixtral-8x7B-Instruct-v0.1",
74
+ provider="Mistral",
75
+ context_length=32768,
76
+ strengths=["Good MoE efficiency", "Solid reasoning"],
77
+ best_for="Agent 3 (Advisor) — Free tier option",
78
+ tier="free"
79
+ ),
80
+ "mistralai/Mistral-7B-Instruct-v0.3": ModelInfo(
81
+ model_id="mistralai/Mistral-7B-Instruct-v0.3",
82
+ provider="Mistral",
83
+ context_length=32768,
84
+ strengths=["Fast", "Good instruction following"],
85
+ best_for="General fallback",
86
+ tier="free"
87
+ ),
88
+ "mistralai/Codestral-22B-v0.1": ModelInfo(
89
+ model_id="mistralai/Codestral-22B-v0.1",
90
+ provider="Mistral",
91
+ context_length=32768,
92
+ strengths=["Code specialist", "JSON generation", "Structured output"],
93
+ best_for="Agent 4 (Generator) — RECOMMENDED",
94
+ tier="pro"
95
+ ),
96
+
97
+ # =========================================================================
98
+ # COHERE — Command R Family (Analysis & Retrieval)
99
+ # =========================================================================
100
+ "CohereForAI/c4ai-command-r-plus": ModelInfo(
101
+ model_id="CohereForAI/c4ai-command-r-plus",
102
+ provider="Cohere",
103
+ context_length=128000,
104
+ strengths=["Excellent analysis", "RAG optimized", "Long context"],
105
+ best_for="Agent 3 (Advisor) — Great for research tasks",
106
+ tier="pro"
107
+ ),
108
+ "CohereForAI/c4ai-command-r-v01": ModelInfo(
109
+ model_id="CohereForAI/c4ai-command-r-v01",
110
+ provider="Cohere",
111
+ context_length=128000,
112
+ strengths=["Good analysis", "Efficient"],
113
+ best_for="Agent 3 budget option",
114
+ tier="free"
115
+ ),
116
+
117
+ # =========================================================================
118
+ # GOOGLE — Gemma Family
119
+ # =========================================================================
120
+ "google/gemma-2-27b-it": ModelInfo(
121
+ model_id="google/gemma-2-27b-it",
122
+ provider="Google",
123
+ context_length=8192,
124
+ strengths=["Strong instruction following", "Good balance"],
125
+ best_for="Agent 2 (Normalizer) — Quality option",
126
+ tier="pro"
127
+ ),
128
+ "google/gemma-2-9b-it": ModelInfo(
129
+ model_id="google/gemma-2-9b-it",
130
+ provider="Google",
131
+ context_length=8192,
132
+ strengths=["Fast", "Good instruction following"],
133
+ best_for="Agent 2 (Normalizer) — Balanced",
134
+ tier="free"
135
+ ),
136
+
137
+ # =========================================================================
138
+ # MICROSOFT — Phi Family (Small but Mighty)
139
+ # =========================================================================
140
+ "microsoft/Phi-3.5-mini-instruct": ModelInfo(
141
+ model_id="microsoft/Phi-3.5-mini-instruct",
142
+ provider="Microsoft",
143
+ context_length=128000,
144
+ strengths=["Very fast", "Great structured output", "Long context"],
145
+ best_for="Agent 2 (Normalizer) — RECOMMENDED",
146
+ tier="free"
147
+ ),
148
+ "microsoft/Phi-3-medium-4k-instruct": ModelInfo(
149
+ model_id="microsoft/Phi-3-medium-4k-instruct",
150
+ provider="Microsoft",
151
+ context_length=4096,
152
+ strengths=["Fast", "Good for simple tasks"],
153
+ best_for="Simple naming tasks",
154
+ tier="free"
155
+ ),
156
+
157
+ # =========================================================================
158
+ # QWEN — Alibaba Family
159
+ # =========================================================================
160
+ "Qwen/Qwen2.5-72B-Instruct": ModelInfo(
161
+ model_id="Qwen/Qwen2.5-72B-Instruct",
162
+ provider="Alibaba",
163
+ context_length=32768,
164
+ strengths=["Strong reasoning", "Multilingual", "Good design knowledge"],
165
+ best_for="Agent 3 (Advisor) — Alternative",
166
+ tier="pro"
167
+ ),
168
+ "Qwen/Qwen2.5-32B-Instruct": ModelInfo(
169
+ model_id="Qwen/Qwen2.5-32B-Instruct",
170
+ provider="Alibaba",
171
+ context_length=32768,
172
+ strengths=["Good balance", "Multilingual"],
173
+ best_for="Medium-tier option",
174
+ tier="pro"
175
+ ),
176
+ "Qwen/Qwen2.5-Coder-32B-Instruct": ModelInfo(
177
+ model_id="Qwen/Qwen2.5-Coder-32B-Instruct",
178
+ provider="Alibaba",
179
+ context_length=32768,
180
+ strengths=["Code specialist", "JSON/structured output"],
181
+ best_for="Agent 4 (Generator) — Alternative",
182
+ tier="pro"
183
+ ),
184
+ "Qwen/Qwen2.5-7B-Instruct": ModelInfo(
185
+ model_id="Qwen/Qwen2.5-7B-Instruct",
186
+ provider="Alibaba",
187
+ context_length=32768,
188
+ strengths=["Fast", "Good all-rounder"],
189
+ best_for="General fallback",
190
+ tier="free"
191
+ ),
192
+
193
+ # =========================================================================
194
+ # DEEPSEEK — Code Specialists
195
+ # =========================================================================
196
+ "deepseek-ai/deepseek-coder-33b-instruct": ModelInfo(
197
+ model_id="deepseek-ai/deepseek-coder-33b-instruct",
198
+ provider="DeepSeek",
199
+ context_length=16384,
200
+ strengths=["Excellent code generation", "JSON specialist"],
201
+ best_for="Agent 4 (Generator) — Code focused",
202
+ tier="pro"
203
+ ),
204
+ "deepseek-ai/DeepSeek-V2.5": ModelInfo(
205
+ model_id="deepseek-ai/DeepSeek-V2.5",
206
+ provider="DeepSeek",
207
+ context_length=32768,
208
+ strengths=["Strong reasoning", "Good code"],
209
+ best_for="Multi-purpose",
210
+ tier="pro"
211
+ ),
212
+
213
+ # =========================================================================
214
+ # BIGCODE — StarCoder Family
215
+ # =========================================================================
216
+ "bigcode/starcoder2-15b-instruct-v0.1": ModelInfo(
217
+ model_id="bigcode/starcoder2-15b-instruct-v0.1",
218
+ provider="BigCode",
219
+ context_length=16384,
220
+ strengths=["Code generation", "Multiple languages"],
221
+ best_for="Agent 4 (Generator) — Open source code model",
222
+ tier="free"
223
+ ),
224
+ }
225
+
226
+
227
+ # =============================================================================
228
+ # RECOMMENDED CONFIGURATIONS BY TIER
229
+ # =============================================================================
230
+
231
+ MODEL_PRESETS = {
232
+ "budget": {
233
+ "name": "Budget (Free Tier)",
234
+ "description": "Best free models for each task",
235
+ "agent2": "microsoft/Phi-3.5-mini-instruct",
236
+ "agent3": "mistralai/Mixtral-8x7B-Instruct-v0.1",
237
+ "agent4": "bigcode/starcoder2-15b-instruct-v0.1",
238
+ "fallback": "mistralai/Mistral-7B-Instruct-v0.3",
239
+ },
240
+ "balanced": {
241
+ "name": "Balanced (Pro Tier)",
242
+ "description": "Good quality/cost balance",
243
+ "agent2": "google/gemma-2-9b-it",
244
+ "agent3": "meta-llama/Llama-3.1-70B-Instruct",
245
+ "agent4": "mistralai/Codestral-22B-v0.1",
246
+ "fallback": "Qwen/Qwen2.5-7B-Instruct",
247
+ },
248
+ "quality": {
249
+ "name": "Maximum Quality (Pro+)",
250
+ "description": "Best models regardless of cost",
251
+ "agent2": "google/gemma-2-27b-it",
252
+ "agent3": "meta-llama/Llama-3.1-405B-Instruct",
253
+ "agent4": "deepseek-ai/deepseek-coder-33b-instruct",
254
+ "fallback": "meta-llama/Llama-3.1-8B-Instruct",
255
+ },
256
+ "diverse": {
257
+ "name": "Diverse Providers",
258
+ "description": "One model from each major provider",
259
+ "agent2": "microsoft/Phi-3.5-mini-instruct", # Microsoft
260
+ "agent3": "CohereForAI/c4ai-command-r-plus", # Cohere
261
+ "agent4": "mistralai/Codestral-22B-v0.1", # Mistral
262
+ "fallback": "meta-llama/Llama-3.1-8B-Instruct", # Meta
263
+ },
264
+ }
265
+
266
+
267
+ # =============================================================================
268
+ # AGENT-SPECIFIC RECOMMENDATIONS
269
+ # =============================================================================
270
+
271
+ AGENT_MODEL_RECOMMENDATIONS = {
272
+ "crawler": {
273
+ "requires_llm": False,
274
+ "notes": "Pure rule-based extraction using Playwright + CSS parsing"
275
+ },
276
+ "extractor": {
277
+ "requires_llm": False,
278
+ "notes": "Pure rule-based extraction using Playwright + CSS parsing"
279
+ },
280
+ "normalizer": {
281
+ "requires_llm": True,
282
+ "task": "Token naming, duplicate detection, pattern inference",
283
+ "needs": ["Fast inference", "Good instruction following", "Structured output"],
284
+ "recommended": [
285
+ ("microsoft/Phi-3.5-mini-instruct", "BEST — Fast, great structured output"),
286
+ ("google/gemma-2-9b-it", "Good balance of speed and quality"),
287
+ ("Qwen/Qwen2.5-7B-Instruct", "Reliable all-rounder"),
288
+ ],
289
+ "temperature": 0.2,
290
+ },
291
+ "advisor": {
292
+ "requires_llm": True,
293
+ "task": "Design system analysis, best practice recommendations",
294
+ "needs": ["Strong reasoning", "Design knowledge", "Creative suggestions"],
295
+ "recommended": [
296
+ ("meta-llama/Llama-3.1-70B-Instruct", "BEST — Excellent reasoning"),
297
+ ("CohereForAI/c4ai-command-r-plus", "Great for analysis tasks"),
298
+ ("Qwen/Qwen2.5-72B-Instruct", "Strong alternative"),
299
+ ("mistralai/Mixtral-8x7B-Instruct-v0.1", "Best free option"),
300
+ ],
301
+ "temperature": 0.4,
302
+ },
303
+ "generator": {
304
+ "requires_llm": True,
305
+ "task": "Generate JSON tokens, CSS variables, structured output",
306
+ "needs": ["Code generation", "JSON formatting", "Schema adherence"],
307
+ "recommended": [
308
+ ("mistralai/Codestral-22B-v0.1", "BEST — Mistral's code model"),
309
+ ("deepseek-ai/deepseek-coder-33b-instruct", "Excellent code specialist"),
310
+ ("Qwen/Qwen2.5-Coder-32B-Instruct", "Strong code model"),
311
+ ("bigcode/starcoder2-15b-instruct-v0.1", "Best free option"),
312
+ ],
313
+ "temperature": 0.1,
314
+ },
315
+ }
316
+
317
+
318
+ # =============================================================================
319
+ # INFERENCE CLIENT
320
+ # =============================================================================
321
+
322
+ class HFInferenceClient:
323
+ """
324
+ Wrapper around HuggingFace Inference API.
325
+
326
+ Handles model selection, retries, and fallbacks.
327
+ """
328
+
329
+ def __init__(self):
330
+ self.settings = get_settings()
331
+ # Read token fresh from env — the Settings singleton may have been
332
+ # created before the user entered their token via the Gradio UI.
333
+ self.token = os.getenv("HF_TOKEN", "") or self.settings.hf.hf_token
334
+
335
+ if not self.token:
336
+ raise ValueError("HF_TOKEN is required for inference")
337
+
338
+ # Let huggingface_hub route to the best available provider automatically.
339
+ # Do NOT set base_url (overrides per-model routing) or
340
+ # provider="hf-inference" (that provider no longer hosts most models).
341
+ # The default provider="auto" picks the first available third-party
342
+ # provider (novita, together, cerebras, etc.) for each model.
343
+ self.sync_client = InferenceClient(token=self.token)
344
+ self.async_client = AsyncInferenceClient(token=self.token)
345
+
346
+ def get_model_for_agent(self, agent_name: str) -> str:
347
+ """Get the appropriate model for an agent."""
348
+ return self.settings.get_model_for_agent(agent_name)
349
+
350
+ def get_temperature_for_agent(self, agent_name: str) -> float:
351
+ """Get recommended temperature for an agent."""
352
+ temps = {
353
+ "normalizer": 0.2, # Consistent naming
354
+ "advisor": 0.4, # Creative recommendations
355
+ "generator": 0.1, # Precise formatting
356
+ }
357
+ return temps.get(agent_name, 0.3)
358
+
359
+ def _build_messages(
360
+ self,
361
+ system_prompt: str,
362
+ user_message: str,
363
+ examples: list[dict] = None
364
+ ) -> list[dict]:
365
+ """Build message list for chat completion."""
366
+ messages = []
367
+
368
+ if system_prompt:
369
+ messages.append({"role": "system", "content": system_prompt})
370
+
371
+ if examples:
372
+ for example in examples:
373
+ messages.append({"role": "user", "content": example["user"]})
374
+ messages.append({"role": "assistant", "content": example["assistant"]})
375
+
376
+ messages.append({"role": "user", "content": user_message})
377
+
378
+ return messages
379
+
380
+ def complete(
381
+ self,
382
+ agent_name: str,
383
+ system_prompt: str,
384
+ user_message: str,
385
+ examples: list[dict] = None,
386
+ max_tokens: int = None,
387
+ temperature: float = None,
388
+ json_mode: bool = False,
389
+ ) -> str:
390
+ """
391
+ Synchronous completion.
392
+
393
+ Args:
394
+ agent_name: Which agent is making the call (for model selection)
395
+ system_prompt: System instructions
396
+ user_message: User input
397
+ examples: Optional few-shot examples
398
+ max_tokens: Max tokens to generate
399
+ temperature: Sampling temperature (uses agent default if not specified)
400
+ json_mode: If True, instruct model to output JSON
401
+
402
+ Returns:
403
+ Generated text
404
+ """
405
+ model = self.get_model_for_agent(agent_name)
406
+ max_tokens = max_tokens or self.settings.hf.max_new_tokens
407
+ temperature = temperature or self.get_temperature_for_agent(agent_name)
408
+
409
+ # Build messages
410
+ if json_mode:
411
+ system_prompt = f"{system_prompt}\n\nYou must respond with valid JSON only. No markdown, no explanation, just JSON."
412
+
413
+ messages = self._build_messages(system_prompt, user_message, examples)
414
+
415
+ try:
416
+ response = self.sync_client.chat_completion(
417
+ model=model,
418
+ messages=messages,
419
+ max_tokens=max_tokens,
420
+ temperature=temperature,
421
+ )
422
+ return response.choices[0].message.content
423
+
424
+ except Exception as e:
425
+ error_msg = str(e)
426
+ print(f"[HF] Primary model {model} failed: {error_msg[:120]}")
427
+ fallback = self.settings.models.fallback_model
428
+ if fallback and fallback != model:
429
+ print(f"[HF] Trying fallback: {fallback}")
430
+ try:
431
+ response = self.sync_client.chat_completion(
432
+ model=fallback,
433
+ messages=messages,
434
+ max_tokens=max_tokens,
435
+ temperature=temperature,
436
+ )
437
+ return response.choices[0].message.content
438
+ except Exception as fallback_err:
439
+ print(f"[HF] Fallback {fallback} also failed: {str(fallback_err)[:120]}")
440
+ raise fallback_err
441
+ raise e
442
+
443
+ async def complete_async(
444
+ self,
445
+ agent_name: str,
446
+ system_prompt: str,
447
+ user_message: str,
448
+ examples: list[dict] = None,
449
+ max_tokens: int = None,
450
+ temperature: float = None,
451
+ json_mode: bool = False,
452
+ ) -> str:
453
+ """
454
+ Asynchronous completion.
455
+
456
+ Same parameters as complete().
457
+ """
458
+ model = self.get_model_for_agent(agent_name)
459
+ max_tokens = max_tokens or self.settings.hf.max_new_tokens
460
+ temperature = temperature or self.get_temperature_for_agent(agent_name)
461
+
462
+ if json_mode:
463
+ system_prompt = f"{system_prompt}\n\nYou must respond with valid JSON only. No markdown, no explanation, just JSON."
464
+
465
+ messages = self._build_messages(system_prompt, user_message, examples)
466
+
467
+ try:
468
+ response = await self.async_client.chat_completion(
469
+ model=model,
470
+ messages=messages,
471
+ max_tokens=max_tokens,
472
+ temperature=temperature,
473
+ )
474
+ return response.choices[0].message.content
475
+
476
+ except Exception as e:
477
+ error_msg = str(e)
478
+ print(f"[HF] Primary model {model} failed: {error_msg[:120]}")
479
+ fallback = self.settings.models.fallback_model
480
+ if fallback and fallback != model:
481
+ print(f"[HF] Trying fallback: {fallback}")
482
+ try:
483
+ response = await self.async_client.chat_completion(
484
+ model=fallback,
485
+ messages=messages,
486
+ max_tokens=max_tokens,
487
+ temperature=temperature,
488
+ )
489
+ return response.choices[0].message.content
490
+ except Exception as fallback_err:
491
+ print(f"[HF] Fallback {fallback} also failed: {str(fallback_err)[:120]}")
492
+ raise fallback_err
493
+ raise e
494
+
495
+ async def stream_async(
496
+ self,
497
+ agent_name: str,
498
+ system_prompt: str,
499
+ user_message: str,
500
+ max_tokens: int = None,
501
+ temperature: float = None,
502
+ ) -> AsyncGenerator[str, None]:
503
+ """
504
+ Async streaming completion.
505
+
506
+ Yields tokens as they are generated.
507
+ """
508
+ model = self.get_model_for_agent(agent_name)
509
+ max_tokens = max_tokens or self.settings.hf.max_new_tokens
510
+ temperature = temperature or self.get_temperature_for_agent(agent_name)
511
+
512
+ messages = self._build_messages(system_prompt, user_message)
513
+
514
+ async for chunk in await self.async_client.chat_completion(
515
+ model=model,
516
+ messages=messages,
517
+ max_tokens=max_tokens,
518
+ temperature=temperature,
519
+ stream=True,
520
+ ):
521
+ if chunk.choices[0].delta.content:
522
+ yield chunk.choices[0].delta.content
523
+
524
+
525
+ # =============================================================================
526
+ # SINGLETON & CONVENIENCE FUNCTIONS
527
+ # =============================================================================
528
+
529
+ _client: Optional[HFInferenceClient] = None
530
+
531
+
532
+ def get_inference_client() -> HFInferenceClient:
533
+ """Get or create the inference client singleton.
534
+
535
+ Re-creates the client if the token has changed (e.g. user entered it
536
+ via the Gradio UI after initial startup).
537
+ """
538
+ global _client
539
+ current_token = os.getenv("HF_TOKEN", "")
540
+ if _client is None or (_client.token != current_token and current_token):
541
+ _client = HFInferenceClient()
542
+ return _client
543
+
544
+
545
+ def complete(
546
+ agent_name: str,
547
+ system_prompt: str,
548
+ user_message: str,
549
+ **kwargs
550
+ ) -> str:
551
+ """Convenience function for sync completion."""
552
+ client = get_inference_client()
553
+ return client.complete(agent_name, system_prompt, user_message, **kwargs)
554
+
555
+
556
+ async def complete_async(
557
+ agent_name: str,
558
+ system_prompt: str,
559
+ user_message: str,
560
+ **kwargs
561
+ ) -> str:
562
+ """Convenience function for async completion."""
563
+ client = get_inference_client()
564
+ return await client.complete_async(agent_name, system_prompt, user_message, **kwargs)
565
+
566
+
567
+ def get_model_info(model_id: str) -> dict:
568
+ """Get information about a specific model."""
569
+ if model_id in AVAILABLE_MODELS:
570
+ info = AVAILABLE_MODELS[model_id]
571
+ return {
572
+ "model_id": info.model_id,
573
+ "provider": info.provider,
574
+ "context_length": info.context_length,
575
+ "strengths": info.strengths,
576
+ "best_for": info.best_for,
577
+ "tier": info.tier,
578
+ }
579
+ return {"model_id": model_id, "provider": "unknown"}
580
+
581
+
582
+ def get_models_by_provider() -> dict[str, list[str]]:
583
+ """Get all models grouped by provider."""
584
+ by_provider = {}
585
+ for model_id, info in AVAILABLE_MODELS.items():
586
+ if info.provider not in by_provider:
587
+ by_provider[info.provider] = []
588
+ by_provider[info.provider].append(model_id)
589
+ return by_provider
590
+
591
+
592
+ def get_models_by_tier(tier: str) -> list[str]:
593
+ """Get all models for a specific tier (free, pro, pro+)."""
594
+ return [
595
+ model_id for model_id, info in AVAILABLE_MODELS.items()
596
+ if info.tier == tier
597
+ ]
598
+
599
+
600
+ def get_preset_config(preset_name: str) -> dict:
601
+ """Get a preset model configuration."""
602
+ return MODEL_PRESETS.get(preset_name, MODEL_PRESETS["balanced"])
core/preview_generator.py ADDED
@@ -0,0 +1,1534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Preview Generator for Typography and Color Previews
3
+
4
+ Generates HTML previews for:
5
+ 1. Typography - Actual font rendering with detected styles
6
+ 2. Colors AS-IS - Simple swatches showing extracted colors (Stage 1)
7
+ 3. Color Ramps - 11 shades (50-950) with AA compliance (Stage 2)
8
+ 4. Spacing AS-IS - Visual spacing blocks
9
+ 5. Radius AS-IS - Rounded corner examples
10
+ 6. Shadows AS-IS - Shadow examples
11
+ """
12
+
13
+ from typing import Optional
14
+ import colorsys
15
+ import re
16
+
17
+
18
+ # =============================================================================
19
+ # STAGE 1: AS-IS PREVIEWS (No enhancements, just raw extracted values)
20
+ # =============================================================================
21
+
22
+ def generate_colors_asis_preview_html(
23
+ color_tokens: dict,
24
+ background: str = "#FAFAFA",
25
+ max_colors: int = 50
26
+ ) -> str:
27
+ """
28
+ Generate HTML preview for AS-IS colors (Stage 1).
29
+
30
+ Shows simple color swatches without generated ramps.
31
+ Sorted by frequency (most used first).
32
+
33
+ Args:
34
+ color_tokens: Dict of colors {name: {value: "#hex", ...}}
35
+ background: Background color
36
+ max_colors: Maximum colors to display (default 50)
37
+
38
+ Returns:
39
+ HTML string for Gradio HTML component
40
+ """
41
+
42
+ # Sort by frequency (highest first)
43
+ sorted_tokens = []
44
+ for name, token in color_tokens.items():
45
+ if isinstance(token, dict):
46
+ freq = token.get("frequency", 0)
47
+ else:
48
+ freq = 0
49
+ sorted_tokens.append((name, token, freq))
50
+
51
+ sorted_tokens.sort(key=lambda x: -x[2]) # Descending by frequency
52
+
53
+ rows_html = ""
54
+
55
+ for name, token, freq in sorted_tokens[:max_colors]:
56
+ # Get hex value
57
+ if isinstance(token, dict):
58
+ hex_val = token.get("value", "#888888")
59
+ frequency = token.get("frequency", 0)
60
+ contexts = token.get("contexts", [])
61
+ contrast_white = token.get("contrast_white", 0)
62
+ contrast_black = token.get("contrast_black", 0)
63
+ else:
64
+ hex_val = str(token)
65
+ frequency = 0
66
+ contexts = []
67
+ contrast_white = 0
68
+ contrast_black = 0
69
+
70
+ # Clean up hex
71
+ if not hex_val.startswith("#"):
72
+ hex_val = f"#{hex_val}"
73
+
74
+ # Determine text color based on background luminance
75
+ # Use contrast ratios to pick best text color
76
+ text_color = "#1a1a1a" if contrast_white and contrast_white < 4.5 else "#ffffff"
77
+ if not contrast_white:
78
+ # Fallback: calculate from hex
79
+ try:
80
+ r = int(hex_val[1:3], 16)
81
+ g = int(hex_val[3:5], 16)
82
+ b = int(hex_val[5:7], 16)
83
+ luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
84
+ text_color = "#1a1a1a" if luminance > 0.5 else "#ffffff"
85
+ except:
86
+ text_color = "#1a1a1a"
87
+
88
+ # Clean name
89
+ display_name = name.replace("_", " ").replace("-", " ").replace(".", " ").title()
90
+ if len(display_name) > 25:
91
+ display_name = display_name[:22] + "..."
92
+
93
+ # AA compliance check
94
+ aa_status = "✓ AA" if contrast_white and contrast_white >= 4.5 else "✗ AA" if contrast_white else ""
95
+ aa_class = "aa-pass" if contrast_white and contrast_white >= 4.5 else "aa-fail"
96
+
97
+ # Context badges (limit to 3)
98
+ context_html = ""
99
+ for ctx in contexts[:3]:
100
+ ctx_display = ctx[:12] + "..." if len(ctx) > 12 else ctx
101
+ context_html += f'<span class="context-badge">{ctx_display}</span>'
102
+
103
+ rows_html += f'''
104
+ <div class="color-row-asis">
105
+ <div class="color-swatch-large" style="background-color: {hex_val};">
106
+ <span class="swatch-hex" style="color: {text_color};">{hex_val}</span>
107
+ </div>
108
+ <div class="color-info-asis">
109
+ <div class="color-name-asis">{display_name}</div>
110
+ <div class="color-meta-asis">
111
+ <span class="frequency">Used {frequency}x</span>
112
+ <span class="{aa_class}">{aa_status}</span>
113
+ </div>
114
+ <div class="context-row">
115
+ {context_html}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ '''
120
+
121
+ # Show count info
122
+ total_colors = len(color_tokens)
123
+ showing = min(max_colors, total_colors)
124
+ count_info = f"Showing {showing} of {total_colors} colors (sorted by frequency)"
125
+
126
+ html = f'''
127
+ <style>
128
+ .colors-asis-header {{
129
+ font-family: system-ui, -apple-system, sans-serif;
130
+ font-size: 14px;
131
+ color: #333 !important;
132
+ margin-bottom: 16px;
133
+ padding: 8px 12px;
134
+ background: #e8e8e8 !important;
135
+ border-radius: 6px;
136
+ }}
137
+
138
+ .colors-asis-preview {{
139
+ font-family: system-ui, -apple-system, sans-serif;
140
+ background: {background} !important;
141
+ border-radius: 12px;
142
+ padding: 20px;
143
+ display: grid;
144
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
145
+ gap: 16px;
146
+ max-height: 800px;
147
+ overflow-y: auto;
148
+ }}
149
+
150
+ .color-row-asis {{
151
+ display: flex;
152
+ align-items: center;
153
+ background: #ffffff !important;
154
+ border-radius: 8px;
155
+ padding: 12px;
156
+ border: 1px solid #d0d0d0 !important;
157
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08);
158
+ }}
159
+
160
+ .color-swatch-large {{
161
+ width: 80px;
162
+ height: 80px;
163
+ border-radius: 8px;
164
+ border: 2px solid rgba(0,0,0,0.15) !important;
165
+ margin-right: 16px;
166
+ flex-shrink: 0;
167
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: center;
171
+ }}
172
+
173
+ .swatch-hex {{
174
+ font-size: 11px;
175
+ font-family: 'SF Mono', Monaco, monospace;
176
+ font-weight: 600;
177
+ text-shadow: 0 1px 2px rgba(0,0,0,0.4);
178
+ }}
179
+
180
+ .color-info-asis {{
181
+ flex: 1;
182
+ min-width: 0;
183
+ }}
184
+
185
+ .color-name-asis {{
186
+ font-weight: 700;
187
+ font-size: 14px;
188
+ color: #1a1a1a !important;
189
+ margin-bottom: 6px;
190
+ white-space: nowrap;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ }}
194
+
195
+ .color-meta-asis {{
196
+ display: flex;
197
+ gap: 12px;
198
+ align-items: center;
199
+ margin-bottom: 6px;
200
+ }}
201
+
202
+ .frequency {{
203
+ font-size: 12px;
204
+ color: #333 !important;
205
+ font-weight: 500;
206
+ }}
207
+
208
+ .context-row {{
209
+ display: flex;
210
+ gap: 6px;
211
+ flex-wrap: wrap;
212
+ }}
213
+
214
+ .context-badge {{
215
+ font-size: 10px;
216
+ background: #d0d0d0 !important;
217
+ padding: 2px 8px;
218
+ border-radius: 4px;
219
+ color: #222 !important;
220
+ }}
221
+
222
+ .aa-pass {{
223
+ font-size: 11px;
224
+ color: #166534 !important;
225
+ font-weight: 700;
226
+ background: #dcfce7 !important;
227
+ padding: 2px 6px;
228
+ border-radius: 4px;
229
+ }}
230
+
231
+ .aa-fail {{
232
+ font-size: 11px;
233
+ color: #991b1b !important;
234
+ font-weight: 700;
235
+ background: #fee2e2 !important;
236
+ padding: 2px 6px;
237
+ border-radius: 4px;
238
+ }}
239
+
240
+ /* Dark mode overrides */
241
+ .dark .colors-asis-header {{ color: #e2e8f0 !important; background: #1e293b !important; }}
242
+ .dark .colors-asis-preview {{ background: #0f172a !important; }}
243
+ .dark .color-row-asis {{ background: #1e293b !important; border-color: #475569 !important; }}
244
+ .dark .color-name-asis {{ color: #f1f5f9 !important; }}
245
+ .dark .frequency {{ color: #cbd5e1 !important; }}
246
+ .dark .context-badge {{ background: #334155 !important; color: #e2e8f0 !important; }}
247
+ .dark .aa-pass {{ color: #22c55e !important; background: #14532d !important; }}
248
+ .dark .aa-fail {{ color: #f87171 !important; background: #450a0a !important; }}
249
+ </style>
250
+
251
+ <div class="colors-asis-header">{count_info}</div>
252
+ <div class="colors-asis-preview">
253
+ {rows_html}
254
+ </div>
255
+ '''
256
+
257
+ return html
258
+
259
+
260
+ def generate_spacing_asis_preview_html(
261
+ spacing_tokens: dict,
262
+ background: str = "#FAFAFA"
263
+ ) -> str:
264
+ """
265
+ Generate HTML preview for AS-IS spacing (Stage 1).
266
+
267
+ Shows visual blocks representing each spacing value.
268
+ """
269
+
270
+ rows_html = ""
271
+
272
+ # Sort by pixel value
273
+ sorted_tokens = []
274
+ for name, token in spacing_tokens.items():
275
+ if isinstance(token, dict):
276
+ value_px = token.get("value_px", 0)
277
+ value = token.get("value", "0px")
278
+ else:
279
+ value = str(token)
280
+ value_px = float(re.sub(r'[^0-9.]', '', value) or 0)
281
+ sorted_tokens.append((name, token, value_px, value))
282
+
283
+ sorted_tokens.sort(key=lambda x: x[2])
284
+
285
+ for name, token, value_px, value in sorted_tokens[:15]:
286
+ # Cap visual width at 200px
287
+ visual_width = min(value_px, 200)
288
+
289
+ rows_html += f'''
290
+ <div class="spacing-row-asis">
291
+ <div class="spacing-label">{value}</div>
292
+ <div class="spacing-bar" style="width: {visual_width}px;"></div>
293
+ </div>
294
+ '''
295
+
296
+ html = f'''
297
+ <style>
298
+ .spacing-asis-preview {{
299
+ font-family: system-ui, -apple-system, sans-serif;
300
+ background: #f5f5f5 !important;
301
+ border-radius: 12px;
302
+ padding: 20px;
303
+ }}
304
+
305
+ .spacing-row-asis {{
306
+ display: flex;
307
+ align-items: center;
308
+ margin-bottom: 12px;
309
+ background: #ffffff !important;
310
+ padding: 8px 12px;
311
+ border-radius: 6px;
312
+ }}
313
+
314
+ .spacing-label {{
315
+ width: 80px;
316
+ font-size: 14px;
317
+ font-weight: 600;
318
+ color: #1a1a1a !important;
319
+ font-family: 'SF Mono', Monaco, monospace;
320
+ }}
321
+
322
+ .spacing-bar {{
323
+ height: 24px;
324
+ background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 100%) !important;
325
+ border-radius: 4px;
326
+ min-width: 4px;
327
+ }}
328
+
329
+ /* Dark mode */
330
+ .dark .spacing-asis-preview {{ background: #0f172a !important; }}
331
+ .dark .spacing-row-asis {{ background: #1e293b !important; }}
332
+ .dark .spacing-label {{ color: #f1f5f9 !important; }}
333
+ </style>
334
+
335
+ <div class="spacing-asis-preview">
336
+ {rows_html}
337
+ </div>
338
+ '''
339
+
340
+ return html
341
+
342
+
343
+ def generate_radius_asis_preview_html(
344
+ radius_tokens: dict,
345
+ background: str = "#FAFAFA"
346
+ ) -> str:
347
+ """
348
+ Generate HTML preview for AS-IS border radius (Stage 1).
349
+
350
+ Shows boxes with each radius value applied.
351
+ """
352
+
353
+ rows_html = ""
354
+
355
+ for name, token in list(radius_tokens.items())[:12]:
356
+ if isinstance(token, dict):
357
+ value = token.get("value", "0px")
358
+ else:
359
+ value = str(token)
360
+
361
+ rows_html += f'''
362
+ <div class="radius-item">
363
+ <div class="radius-box" style="border-radius: {value};"></div>
364
+ <div class="radius-label">{value}</div>
365
+ </div>
366
+ '''
367
+
368
+ html = f'''
369
+ <style>
370
+ .radius-asis-preview {{
371
+ font-family: system-ui, -apple-system, sans-serif;
372
+ background: #f5f5f5 !important;
373
+ border-radius: 12px;
374
+ padding: 20px;
375
+ display: flex;
376
+ flex-wrap: wrap;
377
+ gap: 20px;
378
+ }}
379
+
380
+ .radius-item {{
381
+ display: flex;
382
+ flex-direction: column;
383
+ align-items: center;
384
+ background: #ffffff !important;
385
+ padding: 12px;
386
+ border-radius: 8px;
387
+ }}
388
+
389
+ .radius-box {{
390
+ width: 60px;
391
+ height: 60px;
392
+ background: #3b82f6 !important;
393
+ margin-bottom: 8px;
394
+ }}
395
+
396
+ .radius-label {{
397
+ font-size: 13px;
398
+ font-weight: 600;
399
+ color: #1a1a1a !important;
400
+ font-family: 'SF Mono', Monaco, monospace;
401
+ }}
402
+
403
+ /* Dark mode */
404
+ .dark .radius-asis-preview {{ background: #0f172a !important; }}
405
+ .dark .radius-item {{ background: #1e293b !important; }}
406
+ .dark .radius-label {{ color: #f1f5f9 !important; }}
407
+ </style>
408
+
409
+ <div class="radius-asis-preview">
410
+ {rows_html}
411
+ </div>
412
+ '''
413
+
414
+ return html
415
+
416
+
417
+ def generate_shadows_asis_preview_html(
418
+ shadow_tokens: dict,
419
+ background: str = "#FAFAFA"
420
+ ) -> str:
421
+ """
422
+ Generate HTML preview for AS-IS shadows (Stage 1).
423
+
424
+ Shows cards with each shadow value applied.
425
+ """
426
+
427
+ rows_html = ""
428
+
429
+ for name, token in list(shadow_tokens.items())[:8]:
430
+ if isinstance(token, dict):
431
+ value = token.get("value", "none")
432
+ else:
433
+ value = str(token)
434
+
435
+ # Clean name for display
436
+ display_name = name.replace("_", " ").replace("-", " ").title()
437
+ if len(display_name) > 15:
438
+ display_name = display_name[:12] + "..."
439
+
440
+ rows_html += f'''
441
+ <div class="shadow-item">
442
+ <div class="shadow-box" style="box-shadow: {value};"></div>
443
+ <div class="shadow-label">{display_name}</div>
444
+ <div class="shadow-value">{value[:40]}...</div>
445
+ </div>
446
+ '''
447
+
448
+ html = f'''
449
+ <style>
450
+ .shadows-asis-preview {{
451
+ font-family: system-ui, -apple-system, sans-serif;
452
+ background: #f5f5f5 !important;
453
+ border-radius: 12px;
454
+ padding: 20px;
455
+ display: grid;
456
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
457
+ gap: 24px;
458
+ }}
459
+
460
+ .shadow-item {{
461
+ display: flex;
462
+ flex-direction: column;
463
+ align-items: center;
464
+ background: #e8e8e8 !important;
465
+ padding: 16px;
466
+ border-radius: 8px;
467
+ }}
468
+
469
+ .shadow-box {{
470
+ width: 100px;
471
+ height: 100px;
472
+ background: #ffffff !important;
473
+ border-radius: 8px;
474
+ margin-bottom: 12px;
475
+ }}
476
+
477
+ .shadow-label {{
478
+ font-size: 13px;
479
+ font-weight: 600;
480
+ color: #1a1a1a !important;
481
+ margin-bottom: 4px;
482
+ }}
483
+
484
+ .shadow-value {{
485
+ font-size: 10px;
486
+ color: #444 !important;
487
+ font-family: 'SF Mono', Monaco, monospace;
488
+ text-align: center;
489
+ word-break: break-all;
490
+ }}
491
+
492
+ /* Dark mode */
493
+ .dark .shadows-asis-preview {{ background: #0f172a !important; }}
494
+ .dark .shadow-item {{ background: #1e293b !important; }}
495
+ .dark .shadow-box {{ background: #334155 !important; }}
496
+ .dark .shadow-label {{ color: #f1f5f9 !important; }}
497
+ .dark .shadow-value {{ color: #94a3b8 !important; }}
498
+ </style>
499
+
500
+ <div class="shadows-asis-preview">
501
+ {rows_html}
502
+ </div>
503
+ '''
504
+
505
+ return html
506
+
507
+
508
+ # =============================================================================
509
+ # STAGE 2: TYPOGRAPHY PREVIEW (with rendered font)
510
+ # =============================================================================
511
+
512
+ def generate_typography_preview_html(
513
+ typography_tokens: dict,
514
+ font_family: str = "Open Sans",
515
+ background: str = "#FAFAFA",
516
+ sample_text: str = "The quick brown fox jumps over the lazy dog"
517
+ ) -> str:
518
+ """
519
+ Generate HTML preview for typography tokens.
520
+
521
+ Args:
522
+ typography_tokens: Dict of typography styles {name: {font_size, font_weight, line_height, letter_spacing}}
523
+ font_family: Primary font family detected
524
+ background: Background color (neutral)
525
+ sample_text: Text to render for preview
526
+
527
+ Returns:
528
+ HTML string for Gradio HTML component
529
+ """
530
+
531
+ # Sort tokens by font size (largest first)
532
+ sorted_tokens = []
533
+ for name, token in typography_tokens.items():
534
+ size_str = str(token.get("font_size", "16px"))
535
+ size_num = float(re.sub(r'[^0-9.]', '', size_str) or 16)
536
+ sorted_tokens.append((name, token, size_num))
537
+
538
+ sorted_tokens.sort(key=lambda x: -x[2]) # Descending by size
539
+
540
+ # Generate rows
541
+ rows_html = ""
542
+ for name, token, size_num in sorted_tokens[:15]: # Limit to 15 styles
543
+ font_size = token.get("font_size", "16px")
544
+ font_weight = token.get("font_weight", "400")
545
+ line_height = token.get("line_height", "1.5")
546
+ letter_spacing = token.get("letter_spacing", "0")
547
+
548
+ # Convert weight names to numbers
549
+ weight_map = {
550
+ "thin": 100, "extralight": 200, "light": 300, "regular": 400,
551
+ "medium": 500, "semibold": 600, "bold": 700, "extrabold": 800, "black": 900
552
+ }
553
+ if isinstance(font_weight, str) and font_weight.lower() in weight_map:
554
+ font_weight = weight_map[font_weight.lower()]
555
+
556
+ # Weight label
557
+ weight_labels = {
558
+ 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular",
559
+ 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black"
560
+ }
561
+ weight_label = weight_labels.get(int(font_weight) if str(font_weight).isdigit() else 400, "Regular")
562
+
563
+ # Clean up name for display
564
+ display_name = name.replace("_", " ").replace("-", " ").title()
565
+ if len(display_name) > 15:
566
+ display_name = display_name[:15] + "..."
567
+
568
+ # Truncate sample text for large sizes
569
+ display_text = sample_text
570
+ if size_num > 48:
571
+ display_text = sample_text[:30] + "..."
572
+ elif size_num > 32:
573
+ display_text = sample_text[:40] + "..."
574
+
575
+ rows_html += f'''
576
+ <tr class="meta-row">
577
+ <td class="scale-name">
578
+ <div class="scale-label">{display_name}</div>
579
+ </td>
580
+ <td class="meta">{font_family}</td>
581
+ <td class="meta">{weight_label}</td>
582
+ <td class="meta">{int(size_num)}</td>
583
+ <td class="meta">Sentence</td>
584
+ <td class="meta">{letter_spacing}</td>
585
+ </tr>
586
+ <tr>
587
+ <td colspan="6" class="preview-cell">
588
+ <div class="preview-text" style="
589
+ font-family: '{font_family}', sans-serif;
590
+ font-size: {font_size};
591
+ font-weight: {font_weight};
592
+ line-height: {line_height};
593
+ letter-spacing: {letter_spacing}px;
594
+ ">{display_text}</div>
595
+ </td>
596
+ </tr>
597
+ '''
598
+
599
+ html = f'''
600
+ <style>
601
+ @import url('https://fonts.googleapis.com/css2?family={font_family.replace(" ", "+")}:wght@100;200;300;400;500;600;700;800;900&display=swap');
602
+
603
+ .typography-preview {{
604
+ font-family: system-ui, -apple-system, sans-serif;
605
+ background: {background};
606
+ border-radius: 12px;
607
+ padding: 20px;
608
+ overflow-x: auto;
609
+ }}
610
+
611
+ .typography-preview table {{
612
+ width: 100%;
613
+ border-collapse: collapse;
614
+ }}
615
+
616
+ .typography-preview th {{
617
+ text-align: left;
618
+ padding: 12px 16px;
619
+ font-size: 12px;
620
+ font-weight: 600;
621
+ color: #333;
622
+ text-transform: uppercase;
623
+ letter-spacing: 0.5px;
624
+ border-bottom: 2px solid #E0E0E0;
625
+ background: #F5F5F5;
626
+ }}
627
+
628
+ .typography-preview td {{
629
+ padding: 8px 16px;
630
+ vertical-align: middle;
631
+ }}
632
+
633
+ .typography-preview .meta-row {{
634
+ background: #F8F8F8;
635
+ border-top: 1px solid #E8E8E8;
636
+ }}
637
+
638
+ .typography-preview .scale-name {{
639
+ font-weight: 700;
640
+ color: #1A1A1A;
641
+ min-width: 120px;
642
+ }}
643
+
644
+ .typography-preview .scale-label {{
645
+ font-size: 13px;
646
+ font-weight: 600;
647
+ color: #1A1A1A;
648
+ background: #E8E8E8;
649
+ padding: 4px 8px;
650
+ border-radius: 4px;
651
+ display: inline-block;
652
+ }}
653
+
654
+ .typography-preview .meta {{
655
+ font-size: 13px;
656
+ color: #444;
657
+ white-space: nowrap;
658
+ }}
659
+
660
+ .typography-preview .preview-cell {{
661
+ padding: 16px;
662
+ background: #FFFFFF;
663
+ border-bottom: 1px solid #E8E8E8;
664
+ }}
665
+
666
+ .typography-preview .preview-text {{
667
+ color: #1A1A1A;
668
+ margin: 0;
669
+ word-break: break-word;
670
+ }}
671
+
672
+ .typography-preview tr:hover .preview-cell {{
673
+ background: #F5F5F5;
674
+ }}
675
+
676
+ /* Dark mode */
677
+ .dark .typography-preview {{ background: #1e293b !important; }}
678
+ .dark .typography-preview th {{ background: #334155 !important; color: #e2e8f0 !important; border-bottom-color: #475569 !important; }}
679
+ .dark .typography-preview td {{ color: #e2e8f0 !important; }}
680
+ .dark .typography-preview .meta-row {{ background: #1e293b !important; border-top-color: #334155 !important; }}
681
+ .dark .typography-preview .scale-name,
682
+ .dark .typography-preview .scale-label {{ color: #f1f5f9 !important; background: #475569 !important; }}
683
+ .dark .typography-preview .meta {{ color: #cbd5e1 !important; }}
684
+ .dark .typography-preview .preview-cell {{ background: #0f172a !important; border-bottom-color: #334155 !important; }}
685
+ .dark .typography-preview .preview-text {{ color: #f1f5f9 !important; }}
686
+ .dark .typography-preview tr:hover .preview-cell {{ background: #1e293b !important; }}
687
+ </style>
688
+
689
+ <div class="typography-preview">
690
+ <table>
691
+ <thead>
692
+ <tr>
693
+ <th>Scale Category</th>
694
+ <th>Typeface</th>
695
+ <th>Weight</th>
696
+ <th>Size</th>
697
+ <th>Case</th>
698
+ <th>Letter Spacing</th>
699
+ </tr>
700
+ </thead>
701
+ <tbody>
702
+ {rows_html}
703
+ </tbody>
704
+ </table>
705
+ </div>
706
+ '''
707
+
708
+ return html
709
+
710
+
711
+ # =============================================================================
712
+ # COLOR RAMP PREVIEW
713
+ # =============================================================================
714
+
715
+ def hex_to_rgb(hex_color: str) -> tuple:
716
+ """Convert hex color to RGB tuple."""
717
+ hex_color = hex_color.lstrip('#')
718
+ if len(hex_color) == 3:
719
+ hex_color = ''.join([c*2 for c in hex_color])
720
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
721
+
722
+
723
+ def rgb_to_hex(rgb: tuple) -> str:
724
+ """Convert RGB tuple to hex string."""
725
+ return '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
726
+
727
+
728
+ def get_luminance(rgb: tuple) -> float:
729
+ """Calculate relative luminance for contrast ratio."""
730
+ def adjust(c):
731
+ c = c / 255
732
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
733
+
734
+ r, g, b = rgb
735
+ return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
736
+
737
+
738
+ def get_contrast_ratio(color1: tuple, color2: tuple) -> float:
739
+ """Calculate contrast ratio between two colors."""
740
+ l1 = get_luminance(color1)
741
+ l2 = get_luminance(color2)
742
+ lighter = max(l1, l2)
743
+ darker = min(l1, l2)
744
+ return (lighter + 0.05) / (darker + 0.05)
745
+
746
+
747
+ def generate_color_ramp(base_hex: str) -> list[dict]:
748
+ """
749
+ Generate 11 shades (50-950) from a base color.
750
+
751
+ Uses OKLCH-like approach for perceptually uniform steps.
752
+ """
753
+ try:
754
+ rgb = hex_to_rgb(base_hex)
755
+ except:
756
+ return []
757
+
758
+ # Convert to HLS for easier manipulation
759
+ r, g, b = [x / 255 for x in rgb]
760
+ h, l, s = colorsys.rgb_to_hls(r, g, b)
761
+
762
+ # Define lightness levels for each shade
763
+ # 50 = very light (0.95), 500 = base, 950 = very dark (0.05)
764
+ shade_lightness = {
765
+ 50: 0.95,
766
+ 100: 0.90,
767
+ 200: 0.80,
768
+ 300: 0.70,
769
+ 400: 0.60,
770
+ 500: l, # Keep original lightness for 500
771
+ 600: 0.45,
772
+ 700: 0.35,
773
+ 800: 0.25,
774
+ 900: 0.15,
775
+ 950: 0.08,
776
+ }
777
+
778
+ # Adjust saturation for light/dark shades
779
+ ramp = []
780
+ for shade, target_l in shade_lightness.items():
781
+ # Reduce saturation for very light colors
782
+ if target_l > 0.8:
783
+ adjusted_s = s * 0.6
784
+ elif target_l < 0.2:
785
+ adjusted_s = s * 0.8
786
+ else:
787
+ adjusted_s = s
788
+
789
+ # Generate new RGB
790
+ new_r, new_g, new_b = colorsys.hls_to_rgb(h, target_l, adjusted_s)
791
+ new_rgb = (int(new_r * 255), int(new_g * 255), int(new_b * 255))
792
+ new_hex = rgb_to_hex(new_rgb)
793
+
794
+ # Check AA compliance
795
+ white = (255, 255, 255)
796
+ black = (0, 0, 0)
797
+ contrast_white = get_contrast_ratio(new_rgb, white)
798
+ contrast_black = get_contrast_ratio(new_rgb, black)
799
+
800
+ # AA requires 4.5:1 for normal text
801
+ aa_on_white = contrast_white >= 4.5
802
+ aa_on_black = contrast_black >= 4.5
803
+
804
+ ramp.append({
805
+ "shade": shade,
806
+ "hex": new_hex,
807
+ "rgb": new_rgb,
808
+ "contrast_white": round(contrast_white, 2),
809
+ "contrast_black": round(contrast_black, 2),
810
+ "aa_on_white": aa_on_white,
811
+ "aa_on_black": aa_on_black,
812
+ })
813
+
814
+ return ramp
815
+
816
+
817
+ def generate_color_ramps_preview_html(
818
+ color_tokens: dict,
819
+ background: str = "#FAFAFA",
820
+ max_colors: int = 20
821
+ ) -> str:
822
+ """
823
+ Generate HTML preview for color ramps.
824
+
825
+ Sorts colors by frequency and filters out near-white/near-black
826
+ to prioritize showing actual brand colors.
827
+
828
+ Args:
829
+ color_tokens: Dict of colors {name: {value: "#hex", ...}}
830
+ background: Background color
831
+ max_colors: Maximum colors to show ramps for
832
+
833
+ Returns:
834
+ HTML string for Gradio HTML component
835
+ """
836
+
837
+ def get_color_priority(name, token):
838
+ """Calculate priority score for a color (higher = more important)."""
839
+ if isinstance(token, dict):
840
+ hex_val = token.get("value", "#888888")
841
+ frequency = token.get("frequency", 0)
842
+ else:
843
+ hex_val = str(token)
844
+ frequency = 0
845
+
846
+ # Clean hex
847
+ if not hex_val.startswith("#"):
848
+ hex_val = f"#{hex_val}"
849
+
850
+ # Calculate luminance
851
+ try:
852
+ r = int(hex_val[1:3], 16)
853
+ g = int(hex_val[3:5], 16)
854
+ b = int(hex_val[5:7], 16)
855
+ luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
856
+
857
+ # Calculate saturation (simplified)
858
+ max_c = max(r, g, b)
859
+ min_c = min(r, g, b)
860
+ saturation = (max_c - min_c) / 255 if max_c > 0 else 0
861
+ except:
862
+ luminance = 0.5
863
+ saturation = 0
864
+
865
+ # Priority scoring:
866
+ # - Penalize near-white (luminance > 0.9)
867
+ # - Penalize near-black (luminance < 0.1)
868
+ # - Penalize low saturation (grays)
869
+ # - Reward high frequency
870
+ # - Reward colors with "primary", "brand", "accent" in name
871
+
872
+ score = frequency * 10 # Base score from frequency
873
+
874
+ # Penalize extremes
875
+ if luminance > 0.9:
876
+ score -= 500 # Near white
877
+ if luminance < 0.1:
878
+ score -= 300 # Near black
879
+
880
+ # Reward saturated colors (actual brand colors)
881
+ score += saturation * 200
882
+
883
+ # Reward named brand colors
884
+ name_lower = name.lower()
885
+ if any(kw in name_lower for kw in ['primary', 'brand', 'accent', 'cyan', 'blue', 'green', 'red', 'orange', 'purple']):
886
+ score += 100
887
+
888
+ # Penalize "background", "border", "text" colors
889
+ if any(kw in name_lower for kw in ['background', 'border', 'neutral', 'gray', 'grey']):
890
+ score -= 50
891
+
892
+ return score
893
+
894
+ # Sort colors by priority
895
+ sorted_colors = []
896
+ for name, token in color_tokens.items():
897
+ priority = get_color_priority(name, token)
898
+ sorted_colors.append((name, token, priority))
899
+
900
+ sorted_colors.sort(key=lambda x: -x[2]) # Descending by priority
901
+
902
+ rows_html = ""
903
+ shown_count = 0
904
+
905
+ for name, token, priority in sorted_colors:
906
+ if shown_count >= max_colors:
907
+ break
908
+
909
+ # Get hex value
910
+ if isinstance(token, dict):
911
+ hex_val = token.get("value", "#888888")
912
+ else:
913
+ hex_val = str(token)
914
+
915
+ # Clean up hex
916
+ if not hex_val.startswith("#"):
917
+ hex_val = f"#{hex_val}"
918
+
919
+ # Skip invalid hex
920
+ if len(hex_val) < 7:
921
+ continue
922
+
923
+ # Generate ramp
924
+ ramp = generate_color_ramp(hex_val)
925
+ if not ramp:
926
+ continue
927
+
928
+ # Clean name
929
+ display_name = name.replace("_", " ").replace("-", " ").replace(".", " ").title()
930
+ if len(display_name) > 18:
931
+ display_name = display_name[:15] + "..."
932
+
933
+ # Generate shade cells
934
+ shades_html = ""
935
+ for shade_info in ramp:
936
+ shade = shade_info["shade"]
937
+ hex_color = shade_info["hex"]
938
+ aa_white = shade_info["aa_on_white"]
939
+ aa_black = shade_info["aa_on_black"]
940
+
941
+ # Determine text color for label
942
+ text_color = "#000" if shade < 500 else "#FFF"
943
+
944
+ # AA indicator
945
+ if aa_white or aa_black:
946
+ aa_indicator = "✓"
947
+ aa_class = "aa-pass"
948
+ else:
949
+ aa_indicator = ""
950
+ aa_class = ""
951
+
952
+ shades_html += f'''
953
+ <div class="shade-cell" style="background-color: {hex_color};" title="{hex_color} | AA: {'Pass' if aa_white or aa_black else 'Fail'}">
954
+ <span class="shade-label" style="color: {text_color};">{shade}</span>
955
+ <span class="aa-badge {aa_class}">{aa_indicator}</span>
956
+ </div>
957
+ '''
958
+
959
+ rows_html += f'''
960
+ <div class="color-row">
961
+ <div class="color-info">
962
+ <div class="color-swatch" style="background-color: {hex_val};"></div>
963
+ <div class="color-meta">
964
+ <div class="color-name">{display_name}</div>
965
+ <div class="color-hex">{hex_val}</div>
966
+ </div>
967
+ </div>
968
+ <div class="color-ramp">
969
+ {shades_html}
970
+ </div>
971
+ </div>
972
+ '''
973
+ shown_count += 1
974
+
975
+ # Count info
976
+ total_colors = len(color_tokens)
977
+ count_info = f"Showing {shown_count} of {total_colors} colors (sorted by brand priority)"
978
+
979
+ html = f'''
980
+ <style>
981
+ .color-ramps-preview {{
982
+ font-family: system-ui, -apple-system, sans-serif;
983
+ background: #f5f5f5 !important;
984
+ border-radius: 12px;
985
+ padding: 20px;
986
+ overflow-x: auto;
987
+ }}
988
+
989
+ .color-row {{
990
+ display: flex;
991
+ align-items: center;
992
+ margin-bottom: 16px;
993
+ padding: 12px;
994
+ background: #ffffff !important;
995
+ border-radius: 8px;
996
+ border: 1px solid #d0d0d0 !important;
997
+ }}
998
+
999
+ .color-row:last-child {{
1000
+ margin-bottom: 0;
1001
+ }}
1002
+
1003
+ .color-info {{
1004
+ display: flex;
1005
+ align-items: center;
1006
+ min-width: 160px;
1007
+ margin-right: 20px;
1008
+ }}
1009
+
1010
+ .color-swatch {{
1011
+ width: 44px;
1012
+ height: 44px;
1013
+ border-radius: 8px;
1014
+ border: 2px solid rgba(0,0,0,0.15) !important;
1015
+ margin-right: 12px;
1016
+ flex-shrink: 0;
1017
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
1018
+ }}
1019
+
1020
+ .color-meta {{
1021
+ flex: 1;
1022
+ min-width: 100px;
1023
+ }}
1024
+
1025
+ .color-name {{
1026
+ font-weight: 700;
1027
+ font-size: 13px;
1028
+ color: #1a1a1a !important;
1029
+ margin-bottom: 4px;
1030
+ background: #e0e0e0 !important;
1031
+ padding: 4px 10px;
1032
+ border-radius: 4px;
1033
+ display: inline-block;
1034
+ }}
1035
+
1036
+ .color-hex {{
1037
+ font-size: 12px;
1038
+ color: #333 !important;
1039
+ font-family: 'SF Mono', Monaco, monospace;
1040
+ margin-top: 4px;
1041
+ font-weight: 500;
1042
+ }}
1043
+
1044
+ .color-ramp {{
1045
+ display: flex;
1046
+ gap: 4px;
1047
+ flex: 1;
1048
+ }}
1049
+
1050
+ .shade-cell {{
1051
+ width: 48px;
1052
+ height: 48px;
1053
+ border-radius: 6px;
1054
+ display: flex;
1055
+ flex-direction: column;
1056
+ align-items: center;
1057
+ justify-content: center;
1058
+ position: relative;
1059
+ cursor: pointer;
1060
+ transition: transform 0.15s;
1061
+ border: 1px solid rgba(0,0,0,0.1) !important;
1062
+ }}
1063
+
1064
+ .shade-cell:hover {{
1065
+ transform: scale(1.1);
1066
+ z-index: 10;
1067
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
1068
+ }}
1069
+
1070
+ .shade-label {{
1071
+ font-size: 10px;
1072
+ font-weight: 700;
1073
+ }}
1074
+
1075
+ .aa-badge {{
1076
+ font-size: 12px;
1077
+ margin-top: 2px;
1078
+ font-weight: 700;
1079
+ }}
1080
+
1081
+ .aa-pass {{
1082
+ color: #166534 !important;
1083
+ }}
1084
+
1085
+ .aa-fail {{
1086
+ color: #991b1b !important;
1087
+ }}
1088
+
1089
+ .shade-cell:hover .shade-label,
1090
+ .shade-cell:hover .aa-badge {{
1091
+ opacity: 1;
1092
+ }}
1093
+
1094
+ /* Header row */
1095
+ .ramp-header {{
1096
+ display: flex;
1097
+ margin-bottom: 12px;
1098
+ padding-left: 180px;
1099
+ background: #e8e8e8 !important;
1100
+ padding-top: 8px;
1101
+ padding-bottom: 8px;
1102
+ border-radius: 6px;
1103
+ }}
1104
+
1105
+ .ramp-header-label {{
1106
+ width: 48px;
1107
+ text-align: center;
1108
+ font-size: 12px;
1109
+ font-weight: 700;
1110
+ color: #333 !important;
1111
+ margin-right: 4px;
1112
+ }}
1113
+
1114
+ .ramps-header-info {{
1115
+ font-size: 14px;
1116
+ color: #333 !important;
1117
+ margin-bottom: 16px;
1118
+ padding: 10px 14px;
1119
+ background: #e0e0e0 !important;
1120
+ border-radius: 6px;
1121
+ font-weight: 500;
1122
+ }}
1123
+
1124
+ /* Dark mode */
1125
+ .dark .color-ramps-preview {{ background: #0f172a !important; }}
1126
+ .dark .ramps-header-info {{ color: #e2e8f0 !important; background: #1e293b !important; }}
1127
+ .dark .ramp-header {{ background: #1e293b !important; }}
1128
+ .dark .ramp-header-label {{ color: #cbd5e1 !important; }}
1129
+ .dark .color-row {{ background: #1e293b !important; border-color: #475569 !important; }}
1130
+ .dark .color-name {{ color: #f1f5f9 !important; background: #475569 !important; }}
1131
+ .dark .color-hex {{ color: #cbd5e1 !important; }}
1132
+ </style>
1133
+
1134
+ <div class="color-ramps-preview">
1135
+ <div class="ramps-header-info">{count_info}</div>
1136
+ <div class="ramp-header">
1137
+ <span class="ramp-header-label">50</span>
1138
+ <span class="ramp-header-label">100</span>
1139
+ <span class="ramp-header-label">200</span>
1140
+ <span class="ramp-header-label">300</span>
1141
+ <span class="ramp-header-label">400</span>
1142
+ <span class="ramp-header-label">500</span>
1143
+ <span class="ramp-header-label">600</span>
1144
+ <span class="ramp-header-label">700</span>
1145
+ <span class="ramp-header-label">800</span>
1146
+ <span class="ramp-header-label">900</span>
1147
+ <span class="ramp-header-label">950</span>
1148
+ </div>
1149
+ {rows_html}
1150
+ </div>
1151
+ '''
1152
+
1153
+ return html
1154
+
1155
+
1156
+ # =============================================================================
1157
+ # SEMANTIC COLOR RAMPS WITH LLM RECOMMENDATIONS (Stage 2)
1158
+ # =============================================================================
1159
+
1160
+ def generate_semantic_color_ramps_html(
1161
+ semantic_analysis: dict,
1162
+ color_tokens: dict,
1163
+ llm_recommendations: dict = None,
1164
+ background: str = "#F5F5F5"
1165
+ ) -> str:
1166
+ """
1167
+ Generate HTML preview for colors organized by semantic role with LLM recommendations.
1168
+
1169
+ Args:
1170
+ semantic_analysis: Output from SemanticColorAnalyzer
1171
+ color_tokens: Dict of all color tokens
1172
+ llm_recommendations: LLM suggestions for color improvements
1173
+ background: Background color
1174
+
1175
+ Returns:
1176
+ HTML string for Gradio HTML component
1177
+ """
1178
+
1179
+ def generate_single_ramp(hex_val: str) -> str:
1180
+ """Generate a single color ramp HTML."""
1181
+ ramp = generate_color_ramp(hex_val)
1182
+ if not ramp:
1183
+ return ""
1184
+
1185
+ shades_html = ""
1186
+ for shade_info in ramp:
1187
+ shade = shade_info["shade"]
1188
+ hex_color = shade_info["hex"]
1189
+ aa_white = shade_info["aa_on_white"]
1190
+ aa_black = shade_info["aa_on_black"]
1191
+
1192
+ text_color = "#000" if shade < 500 else "#FFF"
1193
+ aa_indicator = "✓" if aa_white or aa_black else ""
1194
+
1195
+ shades_html += f'''
1196
+ <div class="sem-shade" style="background-color: {hex_color};">
1197
+ <span class="sem-shade-num" style="color: {text_color};">{shade}</span>
1198
+ <span class="sem-shade-aa" style="color: {text_color};">{aa_indicator}</span>
1199
+ </div>
1200
+ '''
1201
+ return shades_html
1202
+
1203
+ def color_row_with_recommendation(hex_val: str, role: str, role_display: str, recommendation: dict = None) -> str:
1204
+ """Generate a color row with optional LLM recommendation."""
1205
+ ramp_html = generate_single_ramp(hex_val)
1206
+
1207
+ # Calculate contrast
1208
+ try:
1209
+ from core.color_utils import get_contrast_with_white
1210
+ contrast = get_contrast_with_white(hex_val)
1211
+ aa_status = "✓ AA" if contrast >= 4.5 else f"⚠️ {contrast:.1f}:1"
1212
+ aa_class = "aa-ok" if contrast >= 4.5 else "aa-warn"
1213
+ except:
1214
+ aa_status = ""
1215
+ aa_class = ""
1216
+
1217
+ # LLM recommendation display
1218
+ rec_html = ""
1219
+ if recommendation:
1220
+ suggested = recommendation.get("suggested", "")
1221
+ issue = recommendation.get("issue", "")
1222
+ if suggested and suggested != hex_val:
1223
+ rec_html = f'''
1224
+ <div class="llm-rec">
1225
+ <span class="rec-label">💡 LLM:</span>
1226
+ <span class="rec-issue">{issue}</span>
1227
+ <span class="rec-arrow">→</span>
1228
+ <span class="rec-suggested" style="background-color: {suggested};">{suggested}</span>
1229
+ </div>
1230
+ '''
1231
+
1232
+ return f'''
1233
+ <div class="sem-color-row">
1234
+ <div class="sem-color-info">
1235
+ <div class="sem-swatch" style="background-color: {hex_val};"></div>
1236
+ <div class="sem-details">
1237
+ <div class="sem-role">{role_display}</div>
1238
+ <div class="sem-hex">{hex_val} <span class="{aa_class}">{aa_status}</span></div>
1239
+ </div>
1240
+ </div>
1241
+ <div class="sem-ramp">{ramp_html}</div>
1242
+ {rec_html}
1243
+ </div>
1244
+ '''
1245
+
1246
+ def category_section(title: str, icon: str, colors: dict, category_key: str) -> str:
1247
+ """Generate a category section with color rows."""
1248
+ if not colors:
1249
+ return ""
1250
+
1251
+ rows_html = ""
1252
+ for role, data in colors.items():
1253
+ if data and isinstance(data, dict) and "hex" in data:
1254
+ # Get LLM recommendation for this role
1255
+ rec = None
1256
+ if llm_recommendations:
1257
+ color_recs = llm_recommendations.get("color_recommendations", {})
1258
+ rec = color_recs.get(f"{category_key}.{role}", {})
1259
+
1260
+ role_display = role.replace("_", " ").title()
1261
+ rows_html += color_row_with_recommendation(
1262
+ data["hex"],
1263
+ f"{category_key}.{role}",
1264
+ role_display,
1265
+ rec
1266
+ )
1267
+
1268
+ if not rows_html:
1269
+ return ""
1270
+
1271
+ return f'''
1272
+ <div class="sem-category">
1273
+ <h3 class="sem-cat-title">{icon} {title}</h3>
1274
+ {rows_html}
1275
+ </div>
1276
+ '''
1277
+
1278
+ # Handle empty analysis
1279
+ if not semantic_analysis:
1280
+ return '''
1281
+ <div class="sem-warning-box" style="padding: 40px; text-align: center; background: #fff3cd; border-radius: 8px;">
1282
+ <p style="color: #856404; font-size: 14px;">⚠️ No semantic analysis available.</p>
1283
+ </div>
1284
+ <style>
1285
+ .dark .sem-warning-box { background: #422006 !important; border-color: #b45309 !important; }
1286
+ .dark .sem-warning-box p { color: #fde68a !important; }
1287
+ </style>
1288
+ '''
1289
+
1290
+ # Build sections
1291
+ sections_html = ""
1292
+ sections_html += category_section("Brand Colors", "🎨", semantic_analysis.get("brand", {}), "brand")
1293
+ sections_html += category_section("Text Colors", "📝", semantic_analysis.get("text", {}), "text")
1294
+ sections_html += category_section("Background Colors", "🖼️", semantic_analysis.get("background", {}), "background")
1295
+ sections_html += category_section("Border Colors", "📏", semantic_analysis.get("border", {}), "border")
1296
+ sections_html += category_section("Feedback Colors", "🚨", semantic_analysis.get("feedback", {}), "feedback")
1297
+
1298
+ # LLM Impact Summary
1299
+ llm_summary = ""
1300
+ if llm_recommendations:
1301
+ changes = llm_recommendations.get("changes_made", [])
1302
+ if changes:
1303
+ changes_html = "".join([f"<li>{c}</li>" for c in changes[:5]])
1304
+ llm_summary = f'''
1305
+ <div class="llm-summary">
1306
+ <h4>🤖 LLM Recommendations Applied:</h4>
1307
+ <ul>{changes_html}</ul>
1308
+ </div>
1309
+ '''
1310
+
1311
+ html = f'''
1312
+ <style>
1313
+ .sem-ramps-preview {{
1314
+ font-family: system-ui, -apple-system, sans-serif;
1315
+ background: #f5f5f5 !important;
1316
+ border-radius: 12px;
1317
+ padding: 20px;
1318
+ }}
1319
+
1320
+ .sem-category {{
1321
+ background: #ffffff !important;
1322
+ border-radius: 8px;
1323
+ padding: 16px;
1324
+ margin-bottom: 20px;
1325
+ border: 1px solid #d0d0d0 !important;
1326
+ }}
1327
+
1328
+ .sem-cat-title {{
1329
+ font-size: 16px;
1330
+ font-weight: 700;
1331
+ color: #1a1a1a !important;
1332
+ margin: 0 0 16px 0;
1333
+ padding-bottom: 8px;
1334
+ border-bottom: 2px solid #e0e0e0 !important;
1335
+ }}
1336
+
1337
+ .sem-color-row {{
1338
+ display: flex;
1339
+ flex-wrap: wrap;
1340
+ align-items: center;
1341
+ padding: 12px;
1342
+ background: #f8f8f8 !important;
1343
+ border-radius: 6px;
1344
+ margin-bottom: 12px;
1345
+ border: 1px solid #e0e0e0 !important;
1346
+ }}
1347
+
1348
+ .sem-color-row:last-child {{
1349
+ margin-bottom: 0;
1350
+ }}
1351
+
1352
+ .sem-color-info {{
1353
+ display: flex;
1354
+ align-items: center;
1355
+ min-width: 180px;
1356
+ margin-right: 16px;
1357
+ }}
1358
+
1359
+ .sem-swatch {{
1360
+ width: 48px;
1361
+ height: 48px;
1362
+ border-radius: 8px;
1363
+ border: 2px solid rgba(0,0,0,0.15) !important;
1364
+ margin-right: 12px;
1365
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
1366
+ }}
1367
+
1368
+ .sem-details {{
1369
+ flex: 1;
1370
+ }}
1371
+
1372
+ .sem-role {{
1373
+ font-weight: 700;
1374
+ font-size: 14px;
1375
+ color: #1a1a1a !important;
1376
+ margin-bottom: 4px;
1377
+ }}
1378
+
1379
+ .sem-hex {{
1380
+ font-size: 12px;
1381
+ font-family: 'SF Mono', Monaco, monospace;
1382
+ color: #333 !important;
1383
+ }}
1384
+
1385
+ .aa-ok {{
1386
+ color: #166534 !important;
1387
+ font-weight: 600;
1388
+ }}
1389
+
1390
+ .aa-warn {{
1391
+ color: #b45309 !important;
1392
+ font-weight: 600;
1393
+ }}
1394
+
1395
+ .sem-ramp {{
1396
+ display: flex;
1397
+ gap: 3px;
1398
+ flex: 1;
1399
+ min-width: 400px;
1400
+ }}
1401
+
1402
+ .sem-shade {{
1403
+ width: 36px;
1404
+ height: 36px;
1405
+ border-radius: 4px;
1406
+ display: flex;
1407
+ flex-direction: column;
1408
+ align-items: center;
1409
+ justify-content: center;
1410
+ border: 1px solid rgba(0,0,0,0.1) !important;
1411
+ }}
1412
+
1413
+ .sem-shade-num {{
1414
+ font-size: 9px;
1415
+ font-weight: 700;
1416
+ }}
1417
+
1418
+ .sem-shade-aa {{
1419
+ font-size: 10px;
1420
+ }}
1421
+
1422
+ .llm-rec {{
1423
+ width: 100%;
1424
+ margin-top: 10px;
1425
+ padding: 8px 12px;
1426
+ background: #fef3c7 !important;
1427
+ border-radius: 4px;
1428
+ display: flex;
1429
+ align-items: center;
1430
+ gap: 8px;
1431
+ border: 1px solid #f59e0b !important;
1432
+ }}
1433
+
1434
+ .rec-label {{
1435
+ font-weight: 600;
1436
+ color: #92400e !important;
1437
+ }}
1438
+
1439
+ .rec-issue {{
1440
+ color: #78350f !important;
1441
+ font-size: 13px;
1442
+ }}
1443
+
1444
+ .rec-arrow {{
1445
+ color: #92400e !important;
1446
+ }}
1447
+
1448
+ .rec-suggested {{
1449
+ padding: 4px 10px;
1450
+ border-radius: 4px;
1451
+ font-family: 'SF Mono', Monaco, monospace;
1452
+ font-size: 12px;
1453
+ font-weight: 600;
1454
+ color: #fff !important;
1455
+ text-shadow: 0 1px 2px rgba(0,0,0,0.3);
1456
+ }}
1457
+
1458
+ .llm-summary {{
1459
+ background: #dbeafe !important;
1460
+ border: 1px solid #3b82f6 !important;
1461
+ border-radius: 8px;
1462
+ padding: 16px;
1463
+ margin-top: 20px;
1464
+ }}
1465
+
1466
+ .llm-summary h4 {{
1467
+ color: #1e40af !important;
1468
+ margin: 0 0 12px 0;
1469
+ font-size: 14px;
1470
+ }}
1471
+
1472
+ .llm-summary ul {{
1473
+ margin: 0;
1474
+ padding-left: 20px;
1475
+ color: #1e3a8a !important;
1476
+ }}
1477
+
1478
+ .llm-summary li {{
1479
+ margin-bottom: 4px;
1480
+ font-size: 13px;
1481
+ }}
1482
+
1483
+ /* Dark mode */
1484
+ .dark .sem-ramps-preview {{ background: #0f172a !important; }}
1485
+ .dark .sem-category {{ background: #1e293b !important; border-color: #475569 !important; }}
1486
+ .dark .sem-cat-title {{ color: #f1f5f9 !important; border-bottom-color: #475569 !important; }}
1487
+ .dark .sem-color-row {{ background: #0f172a !important; border-color: #334155 !important; }}
1488
+ .dark .sem-role {{ color: #f1f5f9 !important; }}
1489
+ .dark .sem-hex {{ color: #cbd5e1 !important; }}
1490
+ .dark .llm-rec {{ background: #422006 !important; border-color: #b45309 !important; }}
1491
+ .dark .rec-label {{ color: #fbbf24 !important; }}
1492
+ .dark .rec-issue {{ color: #fde68a !important; }}
1493
+ .dark .rec-arrow {{ color: #fbbf24 !important; }}
1494
+ .dark .llm-summary {{ background: #1e3a5f !important; border-color: #3b82f6 !important; }}
1495
+ .dark .llm-summary h4 {{ color: #93c5fd !important; }}
1496
+ .dark .llm-summary ul, .dark .llm-summary li {{ color: #bfdbfe !important; }}
1497
+ </style>
1498
+
1499
+ <div class="sem-ramps-preview">
1500
+ {sections_html}
1501
+ {llm_summary}
1502
+ </div>
1503
+ '''
1504
+
1505
+ return html
1506
+
1507
+
1508
+ # =============================================================================
1509
+ # COMBINED PREVIEW
1510
+ # =============================================================================
1511
+
1512
+ def generate_design_system_preview_html(
1513
+ typography_tokens: dict,
1514
+ color_tokens: dict,
1515
+ font_family: str = "Open Sans",
1516
+ sample_text: str = "The quick brown fox jumps over the lazy dog"
1517
+ ) -> tuple[str, str]:
1518
+ """
1519
+ Generate both typography and color ramp previews.
1520
+
1521
+ Returns:
1522
+ Tuple of (typography_html, color_ramps_html)
1523
+ """
1524
+ typography_html = generate_typography_preview_html(
1525
+ typography_tokens=typography_tokens,
1526
+ font_family=font_family,
1527
+ sample_text=sample_text,
1528
+ )
1529
+
1530
+ color_ramps_html = generate_color_ramps_preview_html(
1531
+ color_tokens=color_tokens,
1532
+ )
1533
+
1534
+ return typography_html, color_ramps_html