riazmo commited on
Commit
9a47966
·
verified ·
1 Parent(s): fb953a1

Upload preview_generator.py

Browse files
Files changed (1) hide show
  1. core/preview_generator.py +953 -0
core/preview_generator.py ADDED
@@ -0,0 +1,953 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ) -> str:
26
+ """
27
+ Generate HTML preview for AS-IS colors (Stage 1).
28
+
29
+ Shows simple color swatches without generated ramps.
30
+
31
+ Args:
32
+ color_tokens: Dict of colors {name: {value: "#hex", ...}}
33
+ background: Background color
34
+
35
+ Returns:
36
+ HTML string for Gradio HTML component
37
+ """
38
+
39
+ rows_html = ""
40
+
41
+ for name, token in list(color_tokens.items())[:20]: # Limit to 20 colors
42
+ # Get hex value
43
+ if isinstance(token, dict):
44
+ hex_val = token.get("value", "#888888")
45
+ frequency = token.get("frequency", 0)
46
+ contexts = token.get("contexts", [])
47
+ contrast_white = token.get("contrast_white", 0)
48
+ else:
49
+ hex_val = str(token)
50
+ frequency = 0
51
+ contexts = []
52
+ contrast_white = 0
53
+
54
+ # Clean up hex
55
+ if not hex_val.startswith("#"):
56
+ hex_val = f"#{hex_val}"
57
+
58
+ # Clean name
59
+ display_name = name.replace("_", " ").replace("-", " ").title()
60
+ if len(display_name) > 20:
61
+ display_name = display_name[:17] + "..."
62
+
63
+ # AA compliance check
64
+ aa_status = "✓ AA" if contrast_white and contrast_white >= 4.5 else "✗ AA" if contrast_white else ""
65
+ aa_class = "aa-pass" if contrast_white and contrast_white >= 4.5 else "aa-fail"
66
+
67
+ # Context badges
68
+ context_html = ""
69
+ for ctx in contexts[:2]:
70
+ context_html += f'<span class="context-badge">{ctx}</span>'
71
+
72
+ rows_html += f'''
73
+ <div class="color-row-asis">
74
+ <div class="color-swatch-large" style="background-color: {hex_val};"></div>
75
+ <div class="color-info-asis">
76
+ <div class="color-name-asis">{display_name}</div>
77
+ <div class="color-hex-asis">{hex_val}</div>
78
+ <div class="color-meta-asis">
79
+ <span class="frequency">Used {frequency}x</span>
80
+ {context_html}
81
+ <span class="{aa_class}">{aa_status}</span>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ '''
86
+
87
+ html = f'''
88
+ <style>
89
+ .colors-asis-preview {{
90
+ font-family: system-ui, -apple-system, sans-serif;
91
+ background: {background};
92
+ border-radius: 12px;
93
+ padding: 20px;
94
+ display: grid;
95
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
96
+ gap: 16px;
97
+ }}
98
+
99
+ .color-row-asis {{
100
+ display: flex;
101
+ align-items: center;
102
+ background: #fff;
103
+ border-radius: 8px;
104
+ padding: 12px;
105
+ border: 1px solid #e0e0e0;
106
+ }}
107
+
108
+ .color-swatch-large {{
109
+ width: 60px;
110
+ height: 60px;
111
+ border-radius: 8px;
112
+ border: 2px solid rgba(0,0,0,0.1);
113
+ margin-right: 16px;
114
+ flex-shrink: 0;
115
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
116
+ }}
117
+
118
+ .color-info-asis {{
119
+ flex: 1;
120
+ }}
121
+
122
+ .color-name-asis {{
123
+ font-weight: 700;
124
+ font-size: 14px;
125
+ color: #1a1a1a;
126
+ margin-bottom: 4px;
127
+ }}
128
+
129
+ .color-hex-asis {{
130
+ font-size: 13px;
131
+ color: #444;
132
+ font-family: 'SF Mono', Monaco, monospace;
133
+ margin-bottom: 6px;
134
+ }}
135
+
136
+ .color-meta-asis {{
137
+ display: flex;
138
+ gap: 8px;
139
+ flex-wrap: wrap;
140
+ align-items: center;
141
+ }}
142
+
143
+ .frequency {{
144
+ font-size: 11px;
145
+ color: #666;
146
+ }}
147
+
148
+ .context-badge {{
149
+ font-size: 10px;
150
+ background: #e8e8e8;
151
+ padding: 2px 6px;
152
+ border-radius: 4px;
153
+ color: #555;
154
+ }}
155
+
156
+ .aa-pass {{
157
+ font-size: 11px;
158
+ color: #16a34a;
159
+ font-weight: 600;
160
+ }}
161
+
162
+ .aa-fail {{
163
+ font-size: 11px;
164
+ color: #dc2626;
165
+ font-weight: 600;
166
+ }}
167
+ </style>
168
+
169
+ <div class="colors-asis-preview">
170
+ {rows_html}
171
+ </div>
172
+ '''
173
+
174
+ return html
175
+
176
+
177
+ def generate_spacing_asis_preview_html(
178
+ spacing_tokens: dict,
179
+ background: str = "#FAFAFA"
180
+ ) -> str:
181
+ """
182
+ Generate HTML preview for AS-IS spacing (Stage 1).
183
+
184
+ Shows visual blocks representing each spacing value.
185
+ """
186
+
187
+ rows_html = ""
188
+
189
+ # Sort by pixel value
190
+ sorted_tokens = []
191
+ for name, token in spacing_tokens.items():
192
+ if isinstance(token, dict):
193
+ value_px = token.get("value_px", 0)
194
+ value = token.get("value", "0px")
195
+ else:
196
+ value = str(token)
197
+ value_px = float(re.sub(r'[^0-9.]', '', value) or 0)
198
+ sorted_tokens.append((name, token, value_px, value))
199
+
200
+ sorted_tokens.sort(key=lambda x: x[2])
201
+
202
+ for name, token, value_px, value in sorted_tokens[:15]:
203
+ # Cap visual width at 200px
204
+ visual_width = min(value_px, 200)
205
+
206
+ rows_html += f'''
207
+ <div class="spacing-row-asis">
208
+ <div class="spacing-label">{value}</div>
209
+ <div class="spacing-bar" style="width: {visual_width}px;"></div>
210
+ </div>
211
+ '''
212
+
213
+ html = f'''
214
+ <style>
215
+ .spacing-asis-preview {{
216
+ font-family: system-ui, -apple-system, sans-serif;
217
+ background: {background};
218
+ border-radius: 12px;
219
+ padding: 20px;
220
+ }}
221
+
222
+ .spacing-row-asis {{
223
+ display: flex;
224
+ align-items: center;
225
+ margin-bottom: 12px;
226
+ }}
227
+
228
+ .spacing-label {{
229
+ width: 60px;
230
+ font-size: 13px;
231
+ font-weight: 600;
232
+ color: #333;
233
+ font-family: monospace;
234
+ }}
235
+
236
+ .spacing-bar {{
237
+ height: 24px;
238
+ background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 100%);
239
+ border-radius: 4px;
240
+ min-width: 4px;
241
+ }}
242
+ </style>
243
+
244
+ <div class="spacing-asis-preview">
245
+ {rows_html}
246
+ </div>
247
+ '''
248
+
249
+ return html
250
+
251
+
252
+ def generate_radius_asis_preview_html(
253
+ radius_tokens: dict,
254
+ background: str = "#FAFAFA"
255
+ ) -> str:
256
+ """
257
+ Generate HTML preview for AS-IS border radius (Stage 1).
258
+
259
+ Shows boxes with each radius value applied.
260
+ """
261
+
262
+ rows_html = ""
263
+
264
+ for name, token in list(radius_tokens.items())[:12]:
265
+ if isinstance(token, dict):
266
+ value = token.get("value", "0px")
267
+ else:
268
+ value = str(token)
269
+
270
+ rows_html += f'''
271
+ <div class="radius-item">
272
+ <div class="radius-box" style="border-radius: {value};"></div>
273
+ <div class="radius-label">{value}</div>
274
+ </div>
275
+ '''
276
+
277
+ html = f'''
278
+ <style>
279
+ .radius-asis-preview {{
280
+ font-family: system-ui, -apple-system, sans-serif;
281
+ background: {background};
282
+ border-radius: 12px;
283
+ padding: 20px;
284
+ display: flex;
285
+ flex-wrap: wrap;
286
+ gap: 16px;
287
+ }}
288
+
289
+ .radius-item {{
290
+ display: flex;
291
+ flex-direction: column;
292
+ align-items: center;
293
+ }}
294
+
295
+ .radius-box {{
296
+ width: 60px;
297
+ height: 60px;
298
+ background: #3b82f6;
299
+ margin-bottom: 8px;
300
+ }}
301
+
302
+ .radius-label {{
303
+ font-size: 12px;
304
+ font-weight: 600;
305
+ color: #333;
306
+ font-family: monospace;
307
+ }}
308
+ </style>
309
+
310
+ <div class="radius-asis-preview">
311
+ {rows_html}
312
+ </div>
313
+ '''
314
+
315
+ return html
316
+
317
+
318
+ def generate_shadows_asis_preview_html(
319
+ shadow_tokens: dict,
320
+ background: str = "#FAFAFA"
321
+ ) -> str:
322
+ """
323
+ Generate HTML preview for AS-IS shadows (Stage 1).
324
+
325
+ Shows cards with each shadow value applied.
326
+ """
327
+
328
+ rows_html = ""
329
+
330
+ for name, token in list(shadow_tokens.items())[:8]:
331
+ if isinstance(token, dict):
332
+ value = token.get("value", "none")
333
+ else:
334
+ value = str(token)
335
+
336
+ # Clean name for display
337
+ display_name = name.replace("_", " ").replace("-", " ").title()
338
+ if len(display_name) > 15:
339
+ display_name = display_name[:12] + "..."
340
+
341
+ rows_html += f'''
342
+ <div class="shadow-item">
343
+ <div class="shadow-box" style="box-shadow: {value};"></div>
344
+ <div class="shadow-label">{display_name}</div>
345
+ <div class="shadow-value">{value[:40]}...</div>
346
+ </div>
347
+ '''
348
+
349
+ html = f'''
350
+ <style>
351
+ .shadows-asis-preview {{
352
+ font-family: system-ui, -apple-system, sans-serif;
353
+ background: {background};
354
+ border-radius: 12px;
355
+ padding: 20px;
356
+ display: grid;
357
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
358
+ gap: 24px;
359
+ }}
360
+
361
+ .shadow-item {{
362
+ display: flex;
363
+ flex-direction: column;
364
+ align-items: center;
365
+ }}
366
+
367
+ .shadow-box {{
368
+ width: 100px;
369
+ height: 100px;
370
+ background: #fff;
371
+ border-radius: 8px;
372
+ margin-bottom: 12px;
373
+ }}
374
+
375
+ .shadow-label {{
376
+ font-size: 12px;
377
+ font-weight: 600;
378
+ color: #333;
379
+ margin-bottom: 4px;
380
+ }}
381
+
382
+ .shadow-value {{
383
+ font-size: 10px;
384
+ color: #666;
385
+ font-family: monospace;
386
+ text-align: center;
387
+ word-break: break-all;
388
+ }}
389
+ </style>
390
+
391
+ <div class="shadows-asis-preview">
392
+ {rows_html}
393
+ </div>
394
+ '''
395
+
396
+ return html
397
+
398
+
399
+ # =============================================================================
400
+ # STAGE 2: TYPOGRAPHY PREVIEW (with rendered font)
401
+ # =============================================================================
402
+
403
+ def generate_typography_preview_html(
404
+ typography_tokens: dict,
405
+ font_family: str = "Open Sans",
406
+ background: str = "#FAFAFA",
407
+ sample_text: str = "The quick brown fox jumps over the lazy dog"
408
+ ) -> str:
409
+ """
410
+ Generate HTML preview for typography tokens.
411
+
412
+ Args:
413
+ typography_tokens: Dict of typography styles {name: {font_size, font_weight, line_height, letter_spacing}}
414
+ font_family: Primary font family detected
415
+ background: Background color (neutral)
416
+ sample_text: Text to render for preview
417
+
418
+ Returns:
419
+ HTML string for Gradio HTML component
420
+ """
421
+
422
+ # Sort tokens by font size (largest first)
423
+ sorted_tokens = []
424
+ for name, token in typography_tokens.items():
425
+ size_str = str(token.get("font_size", "16px"))
426
+ size_num = float(re.sub(r'[^0-9.]', '', size_str) or 16)
427
+ sorted_tokens.append((name, token, size_num))
428
+
429
+ sorted_tokens.sort(key=lambda x: -x[2]) # Descending by size
430
+
431
+ # Generate rows
432
+ rows_html = ""
433
+ for name, token, size_num in sorted_tokens[:15]: # Limit to 15 styles
434
+ font_size = token.get("font_size", "16px")
435
+ font_weight = token.get("font_weight", "400")
436
+ line_height = token.get("line_height", "1.5")
437
+ letter_spacing = token.get("letter_spacing", "0")
438
+
439
+ # Convert weight names to numbers
440
+ weight_map = {
441
+ "thin": 100, "extralight": 200, "light": 300, "regular": 400,
442
+ "medium": 500, "semibold": 600, "bold": 700, "extrabold": 800, "black": 900
443
+ }
444
+ if isinstance(font_weight, str) and font_weight.lower() in weight_map:
445
+ font_weight = weight_map[font_weight.lower()]
446
+
447
+ # Weight label
448
+ weight_labels = {
449
+ 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular",
450
+ 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black"
451
+ }
452
+ weight_label = weight_labels.get(int(font_weight) if str(font_weight).isdigit() else 400, "Regular")
453
+
454
+ # Clean up name for display
455
+ display_name = name.replace("_", " ").replace("-", " ").title()
456
+ if len(display_name) > 15:
457
+ display_name = display_name[:15] + "..."
458
+
459
+ # Truncate sample text for large sizes
460
+ display_text = sample_text
461
+ if size_num > 48:
462
+ display_text = sample_text[:30] + "..."
463
+ elif size_num > 32:
464
+ display_text = sample_text[:40] + "..."
465
+
466
+ rows_html += f'''
467
+ <tr class="meta-row">
468
+ <td class="scale-name">
469
+ <div class="scale-label">{display_name}</div>
470
+ </td>
471
+ <td class="meta">{font_family}</td>
472
+ <td class="meta">{weight_label}</td>
473
+ <td class="meta">{int(size_num)}</td>
474
+ <td class="meta">Sentence</td>
475
+ <td class="meta">{letter_spacing}</td>
476
+ </tr>
477
+ <tr>
478
+ <td colspan="6" class="preview-cell">
479
+ <div class="preview-text" style="
480
+ font-family: '{font_family}', sans-serif;
481
+ font-size: {font_size};
482
+ font-weight: {font_weight};
483
+ line-height: {line_height};
484
+ letter-spacing: {letter_spacing}px;
485
+ ">{display_text}</div>
486
+ </td>
487
+ </tr>
488
+ '''
489
+
490
+ html = f'''
491
+ <style>
492
+ @import url('https://fonts.googleapis.com/css2?family={font_family.replace(" ", "+")}:wght@100;200;300;400;500;600;700;800;900&display=swap');
493
+
494
+ .typography-preview {{
495
+ font-family: system-ui, -apple-system, sans-serif;
496
+ background: {background};
497
+ border-radius: 12px;
498
+ padding: 20px;
499
+ overflow-x: auto;
500
+ }}
501
+
502
+ .typography-preview table {{
503
+ width: 100%;
504
+ border-collapse: collapse;
505
+ }}
506
+
507
+ .typography-preview th {{
508
+ text-align: left;
509
+ padding: 12px 16px;
510
+ font-size: 12px;
511
+ font-weight: 600;
512
+ color: #333;
513
+ text-transform: uppercase;
514
+ letter-spacing: 0.5px;
515
+ border-bottom: 2px solid #E0E0E0;
516
+ background: #F5F5F5;
517
+ }}
518
+
519
+ .typography-preview td {{
520
+ padding: 8px 16px;
521
+ vertical-align: middle;
522
+ }}
523
+
524
+ .typography-preview .meta-row {{
525
+ background: #F8F8F8;
526
+ border-top: 1px solid #E8E8E8;
527
+ }}
528
+
529
+ .typography-preview .scale-name {{
530
+ font-weight: 700;
531
+ color: #1A1A1A;
532
+ min-width: 120px;
533
+ }}
534
+
535
+ .typography-preview .scale-label {{
536
+ font-size: 13px;
537
+ font-weight: 600;
538
+ color: #1A1A1A;
539
+ background: #E8E8E8;
540
+ padding: 4px 8px;
541
+ border-radius: 4px;
542
+ display: inline-block;
543
+ }}
544
+
545
+ .typography-preview .meta {{
546
+ font-size: 13px;
547
+ color: #444;
548
+ white-space: nowrap;
549
+ }}
550
+
551
+ .typography-preview .preview-cell {{
552
+ padding: 16px;
553
+ background: #FFFFFF;
554
+ border-bottom: 1px solid #E8E8E8;
555
+ }}
556
+
557
+ .typography-preview .preview-text {{
558
+ color: #1A1A1A;
559
+ margin: 0;
560
+ word-break: break-word;
561
+ }}
562
+
563
+ .typography-preview tr:hover .preview-cell {{
564
+ background: #F5F5F5;
565
+ }}
566
+ </style>
567
+
568
+ <div class="typography-preview">
569
+ <table>
570
+ <thead>
571
+ <tr>
572
+ <th>Scale Category</th>
573
+ <th>Typeface</th>
574
+ <th>Weight</th>
575
+ <th>Size</th>
576
+ <th>Case</th>
577
+ <th>Letter Spacing</th>
578
+ </tr>
579
+ </thead>
580
+ <tbody>
581
+ {rows_html}
582
+ </tbody>
583
+ </table>
584
+ </div>
585
+ '''
586
+
587
+ return html
588
+
589
+
590
+ # =============================================================================
591
+ # COLOR RAMP PREVIEW
592
+ # =============================================================================
593
+
594
+ def hex_to_rgb(hex_color: str) -> tuple:
595
+ """Convert hex color to RGB tuple."""
596
+ hex_color = hex_color.lstrip('#')
597
+ if len(hex_color) == 3:
598
+ hex_color = ''.join([c*2 for c in hex_color])
599
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
600
+
601
+
602
+ def rgb_to_hex(rgb: tuple) -> str:
603
+ """Convert RGB tuple to hex string."""
604
+ return '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
605
+
606
+
607
+ def get_luminance(rgb: tuple) -> float:
608
+ """Calculate relative luminance for contrast ratio."""
609
+ def adjust(c):
610
+ c = c / 255
611
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
612
+
613
+ r, g, b = rgb
614
+ return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
615
+
616
+
617
+ def get_contrast_ratio(color1: tuple, color2: tuple) -> float:
618
+ """Calculate contrast ratio between two colors."""
619
+ l1 = get_luminance(color1)
620
+ l2 = get_luminance(color2)
621
+ lighter = max(l1, l2)
622
+ darker = min(l1, l2)
623
+ return (lighter + 0.05) / (darker + 0.05)
624
+
625
+
626
+ def generate_color_ramp(base_hex: str) -> list[dict]:
627
+ """
628
+ Generate 11 shades (50-950) from a base color.
629
+
630
+ Uses OKLCH-like approach for perceptually uniform steps.
631
+ """
632
+ try:
633
+ rgb = hex_to_rgb(base_hex)
634
+ except:
635
+ return []
636
+
637
+ # Convert to HLS for easier manipulation
638
+ r, g, b = [x / 255 for x in rgb]
639
+ h, l, s = colorsys.rgb_to_hls(r, g, b)
640
+
641
+ # Define lightness levels for each shade
642
+ # 50 = very light (0.95), 500 = base, 950 = very dark (0.05)
643
+ shade_lightness = {
644
+ 50: 0.95,
645
+ 100: 0.90,
646
+ 200: 0.80,
647
+ 300: 0.70,
648
+ 400: 0.60,
649
+ 500: l, # Keep original lightness for 500
650
+ 600: 0.45,
651
+ 700: 0.35,
652
+ 800: 0.25,
653
+ 900: 0.15,
654
+ 950: 0.08,
655
+ }
656
+
657
+ # Adjust saturation for light/dark shades
658
+ ramp = []
659
+ for shade, target_l in shade_lightness.items():
660
+ # Reduce saturation for very light colors
661
+ if target_l > 0.8:
662
+ adjusted_s = s * 0.6
663
+ elif target_l < 0.2:
664
+ adjusted_s = s * 0.8
665
+ else:
666
+ adjusted_s = s
667
+
668
+ # Generate new RGB
669
+ new_r, new_g, new_b = colorsys.hls_to_rgb(h, target_l, adjusted_s)
670
+ new_rgb = (int(new_r * 255), int(new_g * 255), int(new_b * 255))
671
+ new_hex = rgb_to_hex(new_rgb)
672
+
673
+ # Check AA compliance
674
+ white = (255, 255, 255)
675
+ black = (0, 0, 0)
676
+ contrast_white = get_contrast_ratio(new_rgb, white)
677
+ contrast_black = get_contrast_ratio(new_rgb, black)
678
+
679
+ # AA requires 4.5:1 for normal text
680
+ aa_on_white = contrast_white >= 4.5
681
+ aa_on_black = contrast_black >= 4.5
682
+
683
+ ramp.append({
684
+ "shade": shade,
685
+ "hex": new_hex,
686
+ "rgb": new_rgb,
687
+ "contrast_white": round(contrast_white, 2),
688
+ "contrast_black": round(contrast_black, 2),
689
+ "aa_on_white": aa_on_white,
690
+ "aa_on_black": aa_on_black,
691
+ })
692
+
693
+ return ramp
694
+
695
+
696
+ def generate_color_ramps_preview_html(
697
+ color_tokens: dict,
698
+ background: str = "#FAFAFA"
699
+ ) -> str:
700
+ """
701
+ Generate HTML preview for color ramps.
702
+
703
+ Args:
704
+ color_tokens: Dict of colors {name: {value: "#hex", ...}}
705
+ background: Background color
706
+
707
+ Returns:
708
+ HTML string for Gradio HTML component
709
+ """
710
+
711
+ rows_html = ""
712
+
713
+ for name, token in list(color_tokens.items())[:10]: # Limit to 10 colors
714
+ # Get hex value
715
+ if isinstance(token, dict):
716
+ hex_val = token.get("value", "#888888")
717
+ else:
718
+ hex_val = str(token)
719
+
720
+ # Clean up hex
721
+ if not hex_val.startswith("#"):
722
+ hex_val = f"#{hex_val}"
723
+
724
+ # Generate ramp
725
+ ramp = generate_color_ramp(hex_val)
726
+ if not ramp:
727
+ continue
728
+
729
+ # Clean name
730
+ display_name = name.replace("_", " ").replace("-", " ").title()
731
+ if len(display_name) > 15:
732
+ display_name = display_name[:12] + "..."
733
+
734
+ # Generate shade cells
735
+ shades_html = ""
736
+ for shade_info in ramp:
737
+ shade = shade_info["shade"]
738
+ hex_color = shade_info["hex"]
739
+ aa_white = shade_info["aa_on_white"]
740
+ aa_black = shade_info["aa_on_black"]
741
+
742
+ # Determine text color for label
743
+ text_color = "#000" if shade < 500 else "#FFF"
744
+
745
+ # AA indicator
746
+ if aa_white or aa_black:
747
+ aa_indicator = "✓"
748
+ aa_class = "aa-pass"
749
+ else:
750
+ aa_indicator = "✗"
751
+ aa_class = "aa-fail"
752
+
753
+ shades_html += f'''
754
+ <div class="shade-cell" style="background-color: {hex_color};" title="{hex_color} | AA: {'Pass' if aa_white or aa_black else 'Fail'}">
755
+ <span class="shade-label" style="color: {text_color};">{shade}</span>
756
+ <span class="aa-badge {aa_class}">{aa_indicator}</span>
757
+ </div>
758
+ '''
759
+
760
+ rows_html += f'''
761
+ <div class="color-row">
762
+ <div class="color-info">
763
+ <div class="color-swatch" style="background-color: {hex_val};"></div>
764
+ <div class="color-meta">
765
+ <div class="color-name">{display_name}</div>
766
+ <div class="color-hex">{hex_val}</div>
767
+ </div>
768
+ </div>
769
+ <div class="color-ramp">
770
+ {shades_html}
771
+ </div>
772
+ </div>
773
+ '''
774
+
775
+ html = f'''
776
+ <style>
777
+ .color-ramps-preview {{
778
+ font-family: system-ui, -apple-system, sans-serif;
779
+ background: {background};
780
+ border-radius: 12px;
781
+ padding: 20px;
782
+ overflow-x: auto;
783
+ }}
784
+
785
+ .color-row {{
786
+ display: flex;
787
+ align-items: center;
788
+ margin-bottom: 16px;
789
+ padding-bottom: 16px;
790
+ border-bottom: 1px solid #E8E8E8;
791
+ }}
792
+
793
+ .color-row:last-child {{
794
+ border-bottom: none;
795
+ margin-bottom: 0;
796
+ padding-bottom: 0;
797
+ }}
798
+
799
+ .color-info {{
800
+ display: flex;
801
+ align-items: center;
802
+ min-width: 140px;
803
+ margin-right: 20px;
804
+ }}
805
+
806
+ .color-swatch {{
807
+ width: 40px;
808
+ height: 40px;
809
+ border-radius: 8px;
810
+ border: 2px solid rgba(0,0,0,0.15);
811
+ margin-right: 12px;
812
+ flex-shrink: 0;
813
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
814
+ }}
815
+
816
+ .color-meta {{
817
+ flex: 1;
818
+ min-width: 100px;
819
+ }}
820
+
821
+ .color-name {{
822
+ font-weight: 700;
823
+ font-size: 13px;
824
+ color: #1A1A1A;
825
+ margin-bottom: 2px;
826
+ background: #E8E8E8;
827
+ padding: 3px 8px;
828
+ border-radius: 4px;
829
+ display: inline-block;
830
+ }}
831
+
832
+ .color-hex {{
833
+ font-size: 11px;
834
+ color: #444;
835
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
836
+ margin-top: 4px;
837
+ }}
838
+
839
+ .color-ramp {{
840
+ display: flex;
841
+ gap: 4px;
842
+ flex: 1;
843
+ }}
844
+
845
+ .shade-cell {{
846
+ width: 48px;
847
+ height: 48px;
848
+ border-radius: 6px;
849
+ display: flex;
850
+ flex-direction: column;
851
+ align-items: center;
852
+ justify-content: center;
853
+ position: relative;
854
+ cursor: pointer;
855
+ transition: transform 0.15s;
856
+ border: 1px solid rgba(0,0,0,0.1);
857
+ }}
858
+
859
+ .shade-cell:hover {{
860
+ transform: scale(1.1);
861
+ z-index: 10;
862
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
863
+ }}
864
+
865
+ .shade-label {{
866
+ font-size: 10px;
867
+ font-weight: 700;
868
+ }}
869
+
870
+ .aa-badge {{
871
+ font-size: 12px;
872
+ margin-top: 2px;
873
+ font-weight: 700;
874
+ }}
875
+
876
+ .aa-pass {{
877
+ color: #16A34A;
878
+ }}
879
+
880
+ .aa-fail {{
881
+ color: #DC2626;
882
+ }}
883
+
884
+ .shade-cell:hover .shade-label,
885
+ .shade-cell:hover .aa-badge {{
886
+ opacity: 1;
887
+ }}
888
+
889
+ /* Header row */
890
+ .ramp-header {{
891
+ display: flex;
892
+ margin-bottom: 12px;
893
+ padding-left: 160px;
894
+ }}
895
+
896
+ .ramp-header-label {{
897
+ width: 48px;
898
+ text-align: center;
899
+ font-size: 11px;
900
+ font-weight: 600;
901
+ color: #666;
902
+ margin-right: 4px;
903
+ }}
904
+ </style>
905
+
906
+ <div class="color-ramps-preview">
907
+ <div class="ramp-header">
908
+ <span class="ramp-header-label">50</span>
909
+ <span class="ramp-header-label">100</span>
910
+ <span class="ramp-header-label">200</span>
911
+ <span class="ramp-header-label">300</span>
912
+ <span class="ramp-header-label">400</span>
913
+ <span class="ramp-header-label">500</span>
914
+ <span class="ramp-header-label">600</span>
915
+ <span class="ramp-header-label">700</span>
916
+ <span class="ramp-header-label">800</span>
917
+ <span class="ramp-header-label">900</span>
918
+ <span class="ramp-header-label">950</span>
919
+ </div>
920
+ {rows_html}
921
+ </div>
922
+ '''
923
+
924
+ return html
925
+
926
+
927
+ # =============================================================================
928
+ # COMBINED PREVIEW
929
+ # =============================================================================
930
+
931
+ def generate_design_system_preview_html(
932
+ typography_tokens: dict,
933
+ color_tokens: dict,
934
+ font_family: str = "Open Sans",
935
+ sample_text: str = "The quick brown fox jumps over the lazy dog"
936
+ ) -> tuple[str, str]:
937
+ """
938
+ Generate both typography and color ramp previews.
939
+
940
+ Returns:
941
+ Tuple of (typography_html, color_ramps_html)
942
+ """
943
+ typography_html = generate_typography_preview_html(
944
+ typography_tokens=typography_tokens,
945
+ font_family=font_family,
946
+ sample_text=sample_text,
947
+ )
948
+
949
+ color_ramps_html = generate_color_ramps_preview_html(
950
+ color_tokens=color_tokens,
951
+ )
952
+
953
+ return typography_html, color_ramps_html