aghilsabu commited on
Commit
21c534a
·
1 Parent(s): 42c47d4

feat: add Gradio web UI with interactive diagram explorer

Browse files
Files changed (4) hide show
  1. src/ui/__init__.py +17 -0
  2. src/ui/app.py +666 -0
  3. src/ui/components.py +178 -0
  4. src/ui/styles.py +399 -0
src/ui/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CodeAtlas UI Module
3
+
4
+ Gradio-based user interface components.
5
+ """
6
+
7
+ from .app import create_app
8
+ from .components import make_nav_bar, make_loading_html, make_stats_bar
9
+ from .styles import CUSTOM_CSS
10
+
11
+ __all__ = [
12
+ "create_app",
13
+ "make_nav_bar",
14
+ "make_loading_html",
15
+ "make_stats_bar",
16
+ "CUSTOM_CSS",
17
+ ]
src/ui/app.py ADDED
@@ -0,0 +1,666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CodeAtlas UI Application
3
+
4
+ Main Gradio application with multi-page routing.
5
+ Implements the three-page layout: Generate, Explore, Settings.
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import asyncio
11
+ import logging
12
+ import gradio as gr
13
+ from pathlib import Path
14
+ from typing import Optional, Tuple, List, Dict, Any
15
+
16
+ from ..config import get_config, Config, SESSION_FILE, AUDIOS_DIR
17
+ from ..core.repository import RepositoryLoader
18
+ from ..core.analyzer import CodeAnalyzer
19
+ from ..core.diagram import DiagramGenerator, LayoutOptions
20
+ from ..integrations.voice import generate_audio_summary
21
+ from .components import (
22
+ make_nav_bar,
23
+ make_loading_html,
24
+ make_stats_bar,
25
+ make_error_html,
26
+ make_empty_state_html,
27
+ make_hero_section,
28
+ make_footer,
29
+ )
30
+ from .styles import CUSTOM_CSS
31
+
32
+ logger = logging.getLogger("codeatlas.ui")
33
+
34
+ # Global instances
35
+ _repository_loader: Optional[RepositoryLoader] = None
36
+ _diagram_generator: Optional[DiagramGenerator] = None
37
+
38
+
39
+ def get_repository_loader() -> RepositoryLoader:
40
+ """Get or create repository loader instance."""
41
+ global _repository_loader
42
+ if _repository_loader is None:
43
+ _repository_loader = RepositoryLoader()
44
+ return _repository_loader
45
+
46
+
47
+ def get_diagram_generator() -> DiagramGenerator:
48
+ """Get or create diagram generator instance."""
49
+ global _diagram_generator
50
+ if _diagram_generator is None:
51
+ _diagram_generator = DiagramGenerator()
52
+ return _diagram_generator
53
+
54
+
55
+ # Session state management
56
+ def load_session_state() -> Dict[str, Any]:
57
+ """Load session state from file."""
58
+ try:
59
+ if SESSION_FILE.exists():
60
+ with open(SESSION_FILE, "r") as f:
61
+ return json.load(f)
62
+ except Exception as e:
63
+ logger.warning(f"Failed to load session: {e}")
64
+ return {
65
+ "dot_source": None,
66
+ "repo_name": "",
67
+ "stats": {},
68
+ "api_key": "",
69
+ "openai_api_key": "",
70
+ "elevenlabs_api_key": "",
71
+ "model": "Gemini 2.5 Pro",
72
+ "pending_request": None,
73
+ }
74
+
75
+
76
+ def save_session_state(data: Dict[str, Any]) -> bool:
77
+ """Save session state to file."""
78
+ try:
79
+ existing = load_session_state()
80
+ existing.update(data)
81
+ with open(SESSION_FILE, "w") as f:
82
+ json.dump(existing, f)
83
+ return True
84
+ except Exception as e:
85
+ logger.warning(f"Failed to save session: {e}")
86
+ return False
87
+
88
+
89
+ def get_current_model() -> str:
90
+ """Get the current model from session state."""
91
+ config = get_config()
92
+ return config.current_model
93
+
94
+
95
+ def get_model_choices() -> List[str]:
96
+ """Get available model choices."""
97
+ config = get_config()
98
+ return list(config.models.all_models.keys())
99
+
100
+
101
+ def create_app():
102
+ """Create the Gradio application with multi-page routing."""
103
+ config = get_config()
104
+
105
+ # ==================== MAIN PAGE (Generate) ====================
106
+ with gr.Blocks(title="CodeAtlas - AI Codebase Visualizer", fill_height=True) as app:
107
+ gr.Navbar(visible=False)
108
+
109
+ # State
110
+ file_input = gr.State(value=None)
111
+
112
+ # Top nav bar
113
+ with gr.Row(elem_classes="nav-bar-row"):
114
+ nav_bar = gr.HTML(make_nav_bar("generate"))
115
+ model_selector = gr.Dropdown(
116
+ choices=get_model_choices(),
117
+ value=get_current_model(),
118
+ show_label=False,
119
+ container=False,
120
+ scale=0,
121
+ min_width=180,
122
+ elem_classes="model-dropdown-nav"
123
+ )
124
+
125
+ # Hero section
126
+ gr.HTML(make_hero_section())
127
+
128
+ # Input section
129
+ with gr.Row():
130
+ gr.Column(scale=1, min_width=50)
131
+ with gr.Column(scale=3, min_width=400):
132
+ github_input = gr.Textbox(
133
+ placeholder="github.com/owner/repo or paste a GitHub URL",
134
+ label="GitHub Repository",
135
+ lines=1,
136
+ )
137
+ with gr.Row():
138
+ analyze_btn = gr.Button("🚀 Generate Diagram", variant="primary", scale=2)
139
+ upload_btn = gr.UploadButton("📁 Upload ZIP", file_types=[".zip"], scale=1, variant="secondary")
140
+
141
+ error_msg = gr.HTML(visible=False)
142
+ gr.Column(scale=1, min_width=50)
143
+
144
+ # Footer
145
+ gr.HTML(make_footer())
146
+
147
+ # Event handlers
148
+ def start_analysis(file_path, github_url, selected_model):
149
+ """Validate input and prepare for analysis."""
150
+ logger.info(f"start_analysis: file={file_path}, url={github_url}, model={selected_model}")
151
+
152
+ if not file_path and (not github_url or not github_url.strip()):
153
+ raise gr.Error("Please enter a GitHub URL or upload a ZIP file")
154
+
155
+ session = load_session_state()
156
+ if not session.get("api_key"):
157
+ raise gr.Error("API Key not configured. Please go to Settings first.")
158
+
159
+ # Save model selection and pending request
160
+ save_session_state({
161
+ "model": selected_model,
162
+ "pending_request": {
163
+ "github_url": github_url.strip() if github_url else None,
164
+ "file_path": file_path,
165
+ },
166
+ "dot_source": None,
167
+ "repo_name": "",
168
+ "stats": {},
169
+ })
170
+
171
+ return gr.update(visible=False), None, True
172
+
173
+ do_redirect = gr.State(False)
174
+
175
+ # Wire up events
176
+ for trigger in [analyze_btn.click, github_input.submit]:
177
+ trigger(
178
+ fn=start_analysis,
179
+ inputs=[file_input, github_input, model_selector],
180
+ outputs=[error_msg, file_input, do_redirect]
181
+ ).success(
182
+ fn=None,
183
+ js="() => { window.location.href = '/explore'; }"
184
+ )
185
+
186
+ upload_btn.upload(
187
+ fn=lambda f, m: start_analysis(f, "", m),
188
+ inputs=[upload_btn, model_selector],
189
+ outputs=[error_msg, file_input, do_redirect]
190
+ ).success(
191
+ fn=None,
192
+ js="() => { window.location.href = '/explore'; }"
193
+ )
194
+
195
+ # Model change handler
196
+ def on_model_change(model):
197
+ save_session_state({"model": model})
198
+ config.current_model = model
199
+ config.save_to_session()
200
+
201
+ model_selector.change(fn=on_model_change, inputs=[model_selector])
202
+ app.load(fn=get_current_model, outputs=[model_selector])
203
+
204
+ # ==================== EXPLORE PAGE ====================
205
+ with app.route("explore") as explore_page:
206
+ current_dot = gr.State(value=None)
207
+ chat_history = gr.State(value=[])
208
+
209
+ # Nav bar
210
+ with gr.Row(elem_classes="nav-bar-row"):
211
+ explore_nav = gr.HTML(make_nav_bar("explore"))
212
+ explore_model = gr.Dropdown(
213
+ choices=get_model_choices(),
214
+ value=get_current_model(),
215
+ show_label=False,
216
+ container=False,
217
+ scale=0,
218
+ min_width=180,
219
+ elem_classes="model-dropdown-nav"
220
+ )
221
+
222
+ # Left sidebar - History & Layout
223
+ with gr.Sidebar(position="left", open=False):
224
+ gr.Markdown("#### 📜 History")
225
+ history_dropdown = gr.Dropdown(
226
+ choices=[],
227
+ label="Saved Diagrams",
228
+ interactive=True,
229
+ )
230
+ with gr.Row():
231
+ load_history_btn = gr.Button("Load", variant="primary", size="sm", scale=2)
232
+ refresh_history_btn = gr.Button("🔄", variant="secondary", size="sm", scale=1, min_width=40)
233
+
234
+ gr.Markdown("---")
235
+ gr.Markdown("#### 📐 Layout")
236
+ layout_direction = gr.Dropdown(
237
+ choices=["Top → Down", "Left → Right", "Bottom → Up", "Right → Left"],
238
+ value="Top → Down",
239
+ label="Direction",
240
+ )
241
+ layout_splines = gr.Dropdown(
242
+ choices=["polyline", "ortho", "spline", "line"],
243
+ value="polyline",
244
+ label="Edge Style",
245
+ )
246
+ layout_nodesep = gr.Slider(0.1, 2.0, 0.5, step=0.1, label="Node Spacing")
247
+ layout_ranksep = gr.Slider(0.25, 3.0, 0.75, step=0.25, label="Layer Spacing")
248
+ zoom_slider = gr.Slider(0.25, 3.0, 1.0, step=0.1, label="Zoom")
249
+ apply_layout_btn = gr.Button("Apply Changes", variant="primary")
250
+
251
+ # Right sidebar - Audio & Chat
252
+ with gr.Sidebar(position="right", open=False, width=400, elem_classes="sidebar-right"):
253
+ with gr.Row(elem_classes="audio-row"):
254
+ audio_gen_btn = gr.Button("🔊 Generate Audio", variant="primary", size="sm", elem_classes="audio-gen-btn")
255
+ audio_status = gr.HTML("", elem_classes="audio-status")
256
+ audio_player = gr.Audio(
257
+ label=None,
258
+ show_label=False,
259
+ type="filepath",
260
+ sources=[],
261
+ interactive=False,
262
+ elem_classes="audio-player-compact"
263
+ )
264
+
265
+ gr.HTML('<div style="font-size: 0.85rem; font-weight: 600; color: #374151; padding: 0.5rem 0;">💬 Ask About Code</div>')
266
+
267
+ chatbot = gr.Chatbot(
268
+ show_label=False,
269
+ placeholder="Ask questions about the architecture...",
270
+ elem_id="codeatlas-chat",
271
+ avatar_images=(None, "https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg"),
272
+ layout="panel",
273
+ autoscroll=True
274
+ )
275
+
276
+ with gr.Column(elem_classes="chat-input-container"):
277
+ with gr.Row(elem_classes="chat-input-row"):
278
+ chat_input = gr.Textbox(
279
+ placeholder="Ask about the architecture...",
280
+ show_label=False,
281
+ scale=6,
282
+ container=False,
283
+ lines=1,
284
+ )
285
+ chat_send_btn = gr.Button("➤", variant="primary", size="sm", scale=1, min_width=40)
286
+
287
+ # Main content
288
+ stats_output = gr.HTML("")
289
+ diagram_output = gr.HTML(make_loading_html("⏳", "Loading..."))
290
+
291
+ # Async process function
292
+ async def process_pending_request():
293
+ """Process a pending analysis request with streaming updates."""
294
+ session = load_session_state()
295
+ pending = session.get("pending_request")
296
+
297
+ # No pending request - show existing or empty
298
+ if not pending or not (pending.get("github_url") or pending.get("file_path")):
299
+ dot_source = session.get("dot_source")
300
+ if dot_source:
301
+ generator = get_diagram_generator()
302
+ diagram = generator.render(dot_source, repo_name=session.get("repo_name", ""))
303
+
304
+ # Count nodes/edges for existing diagram
305
+ node_count, edge_count = generator._count_nodes_edges(dot_source)
306
+
307
+ stats = make_stats_bar(
308
+ repo_name=session.get("repo_name", ""),
309
+ files_processed=session.get("stats", {}).get("files_processed", 0),
310
+ total_characters=session.get("stats", {}).get("total_characters", 0),
311
+ model_name=session.get("model", ""),
312
+ node_count=node_count,
313
+ edge_count=edge_count,
314
+ )
315
+ yield diagram, stats, dot_source
316
+ else:
317
+ yield make_empty_state_html(), "", None
318
+ return
319
+
320
+ # Get request details
321
+ github_url = pending.get("github_url")
322
+ file_path = pending.get("file_path")
323
+ model_choice = session.get("model", "Gemini 2.5 Pro")
324
+
325
+ config = get_config()
326
+ model_name = config.models.get_model_id(model_choice)
327
+
328
+ # Get API key
329
+ if config.models.is_openai_model(model_name):
330
+ api_key = session.get("openai_api_key", "")
331
+ if not api_key:
332
+ yield make_error_html("OpenAI API key required", "🔑", "/settings", "Add Key →"), "", None
333
+ return
334
+ else:
335
+ api_key = session.get("api_key", "")
336
+ if not api_key:
337
+ yield make_error_html("Gemini API key required", "🔑", "/settings", "Add Key →"), "", None
338
+ return
339
+
340
+ # Clear pending request
341
+ save_session_state({"pending_request": None})
342
+
343
+ # Step 1: Download/Process
344
+ display_name = ""
345
+ yield make_loading_html("📥", "Downloading repository..." if github_url else "Processing file..."), "", None
346
+ await asyncio.sleep(0.1) # Allow UI to update
347
+
348
+ loader = get_repository_loader()
349
+ if github_url:
350
+ result = loader.load_from_github(github_url)
351
+ parts = github_url.rstrip("/").split("/")
352
+ display_name = "/".join(parts[-2:]) if len(parts) >= 2 else github_url
353
+ else:
354
+ result = loader.load_from_file(file_path)
355
+ display_name = Path(file_path).stem if file_path else "upload"
356
+
357
+ if result.error:
358
+ yield make_error_html(result.error), "", None
359
+ return
360
+
361
+ # Step 2: Show files found (display for 2s while not blocking)
362
+ yield make_loading_html(
363
+ "🔍",
364
+ f"Extracted {result.stats.files_processed} files",
365
+ f"{result.stats.total_characters:,} characters • Preparing AI analysis..."
366
+ ), "", None
367
+ await asyncio.sleep(1.5) # Show extraction results for 2 seconds
368
+
369
+ # Step 3: AI Analysis - this step shows while actual analysis happens
370
+ yield make_loading_html(
371
+ "🧠",
372
+ "AI analyzing code structure...",
373
+ f"Using {model_choice}"
374
+ ), "", None
375
+ await asyncio.sleep(1.5) # Brief pause to render before heavy work
376
+
377
+ # Step 4: Generate diagram
378
+ try:
379
+ yield make_loading_html("🗺️", "Generating architecture diagram...", f"{model_choice} • This may take a moment..."), "", None
380
+ await asyncio.sleep(0.1) # Allow UI to update
381
+
382
+ analyzer = CodeAnalyzer(api_key=api_key, model_name=model_name)
383
+ analysis = analyzer.generate_diagram(result.context)
384
+
385
+ if not analysis.success:
386
+ yield make_error_html(analysis.error), "", None
387
+ return
388
+
389
+ # Prepare metadata for saving
390
+ diagram_metadata = {
391
+ "model_name": model_choice,
392
+ "files_processed": result.stats.files_processed,
393
+ "total_characters": result.stats.total_characters,
394
+ }
395
+
396
+ # Save results
397
+ save_session_state({
398
+ "dot_source": analysis.content,
399
+ "repo_name": display_name,
400
+ "stats": result.stats.as_dict,
401
+ "model": model_choice,
402
+ })
403
+
404
+ # Render diagram with metadata
405
+ generator = get_diagram_generator()
406
+ diagram = generator.render(
407
+ analysis.content,
408
+ repo_name=display_name,
409
+ save_to_history=True,
410
+ metadata=diagram_metadata
411
+ )
412
+
413
+ # Count nodes/edges for stats bar
414
+ node_count, edge_count = generator._count_nodes_edges(analysis.content)
415
+
416
+ stats = make_stats_bar(
417
+ repo_name=display_name,
418
+ files_processed=result.stats.files_processed,
419
+ total_characters=result.stats.total_characters,
420
+ model_name=model_choice,
421
+ node_count=node_count,
422
+ edge_count=edge_count,
423
+ )
424
+
425
+ yield diagram, stats, analysis.content
426
+
427
+ except Exception as e:
428
+ logger.exception("Analysis failed")
429
+ yield make_error_html(str(e)), "", None
430
+
431
+ def apply_layout(dot_source, direction, splines, nodesep, ranksep, zoom):
432
+ """Apply layout changes to the diagram."""
433
+ if not dot_source:
434
+ return make_empty_state_html("No diagram to adjust.")
435
+
436
+ layout = LayoutOptions.from_ui(direction, splines, nodesep, ranksep, zoom)
437
+ generator = get_diagram_generator()
438
+ return generator.render(dot_source, layout)
439
+
440
+ def load_from_history(selected):
441
+ """Load a diagram from history with metadata."""
442
+ if not selected:
443
+ return make_empty_state_html("Select a diagram."), "", None, [], []
444
+
445
+ generator = get_diagram_generator()
446
+ dot_source, metadata = generator.load_from_history_with_metadata(selected)
447
+
448
+ if not dot_source:
449
+ return make_error_html("Diagram not found"), "", None, [], []
450
+
451
+ # Extract repo name from filename or metadata
452
+ name = selected.replace("raw_", "").replace(".dot", "")
453
+ parts = name.split("_")
454
+ repo_name = metadata.get("repo_name") if metadata else None
455
+ if not repo_name:
456
+ repo_name = "_".join(parts[:-2]) if len(parts) > 2 else parts[0] if parts else "local"
457
+
458
+ diagram = generator.render(dot_source, repo_name=repo_name)
459
+
460
+ # Always count nodes/edges from DOT source for accurate stats
461
+ node_count, edge_count = generator._count_nodes_edges(dot_source)
462
+
463
+ # Build stats bar with all available metadata
464
+ stats = make_stats_bar(
465
+ repo_name=repo_name,
466
+ files_processed=metadata.get("files_processed", 0) if metadata else 0,
467
+ total_characters=metadata.get("total_characters", 0) if metadata else 0,
468
+ model_name=metadata.get("model_name", "") if metadata else "",
469
+ node_count=node_count,
470
+ edge_count=edge_count,
471
+ extra_info="📂 From history",
472
+ )
473
+
474
+ return diagram, stats, dot_source, [], []
475
+
476
+ def chat_about_diagram(message, history, dot_source):
477
+ """Chat about the loaded diagram."""
478
+ if not message or not message.strip():
479
+ return history, ""
480
+
481
+ message = message.strip()
482
+ history = history or []
483
+
484
+ if not dot_source:
485
+ history = history + [
486
+ {"role": "user", "content": message},
487
+ {"role": "assistant", "content": "⚠️ No diagram loaded. Please generate or load one first."}
488
+ ]
489
+ return history, ""
490
+
491
+ session = load_session_state()
492
+ api_key = session.get("api_key", "")
493
+ model_choice = session.get("model", "Gemini 2.5 Pro")
494
+
495
+ if not api_key:
496
+ history = history + [
497
+ {"role": "user", "content": message},
498
+ {"role": "assistant", "content": "⚠️ API key not configured. Go to Settings."}
499
+ ]
500
+ return history, ""
501
+
502
+ try:
503
+ config = get_config()
504
+ model_name = config.models.get_model_id(model_choice)
505
+ analyzer = CodeAnalyzer(api_key=api_key, model_name=model_name)
506
+
507
+ result = analyzer.chat(message, dot_source, history)
508
+
509
+ response = result.content if result.success else f"❌ {result.error}"
510
+ history = history + [
511
+ {"role": "user", "content": message},
512
+ {"role": "assistant", "content": response}
513
+ ]
514
+
515
+ except Exception as e:
516
+ logger.exception("Chat error")
517
+ history = history + [
518
+ {"role": "user", "content": message},
519
+ {"role": "assistant", "content": f"❌ Error: {str(e)}"}
520
+ ]
521
+
522
+ return history, ""
523
+
524
+ def handle_audio_gen(dot_source):
525
+ """Generate audio summary."""
526
+ audio_path, status = generate_audio_summary(dot_source)
527
+ if audio_path and audio_path.exists():
528
+ return status, str(audio_path)
529
+ return status, None
530
+
531
+ def refresh_history_choices():
532
+ """Refresh the history dropdown with latest diagrams."""
533
+ choices = get_diagram_generator().get_history_choices()
534
+ return gr.update(choices=choices, value=None)
535
+
536
+ # Event wiring
537
+ explore_page.load(fn=process_pending_request, outputs=[diagram_output, stats_output, current_dot])
538
+ explore_page.load(fn=lambda: [], outputs=[chat_history])
539
+ explore_page.load(fn=refresh_history_choices, outputs=[history_dropdown])
540
+ explore_page.load(fn=get_current_model, outputs=[explore_model])
541
+
542
+ apply_layout_btn.click(
543
+ fn=apply_layout,
544
+ inputs=[current_dot, layout_direction, layout_splines, layout_nodesep, layout_ranksep, zoom_slider],
545
+ outputs=[diagram_output]
546
+ )
547
+
548
+ refresh_history_btn.click(
549
+ fn=refresh_history_choices,
550
+ outputs=[history_dropdown]
551
+ )
552
+
553
+ load_history_btn.click(
554
+ fn=load_from_history,
555
+ inputs=[history_dropdown],
556
+ outputs=[diagram_output, stats_output, current_dot, chat_history, chatbot]
557
+ )
558
+
559
+ chat_send_btn.click(
560
+ fn=chat_about_diagram,
561
+ inputs=[chat_input, chatbot, current_dot],
562
+ outputs=[chatbot, chat_input]
563
+ )
564
+
565
+ chat_input.submit(
566
+ fn=chat_about_diagram,
567
+ inputs=[chat_input, chatbot, current_dot],
568
+ outputs=[chatbot, chat_input]
569
+ )
570
+
571
+ audio_gen_btn.click(
572
+ fn=handle_audio_gen,
573
+ inputs=[current_dot],
574
+ outputs=[audio_status, audio_player]
575
+ )
576
+
577
+ explore_model.change(
578
+ fn=lambda m: save_session_state({"model": m}),
579
+ inputs=[explore_model]
580
+ )
581
+
582
+ # ==================== SETTINGS PAGE ====================
583
+ with app.route("settings") as settings_page:
584
+ with gr.Row(elem_classes="nav-bar-row"):
585
+ settings_nav = gr.HTML(make_nav_bar("settings"))
586
+ settings_model = gr.Dropdown(
587
+ choices=get_model_choices(),
588
+ value=get_current_model(),
589
+ show_label=False,
590
+ container=False,
591
+ scale=0,
592
+ min_width=180,
593
+ elem_classes="model-dropdown-nav"
594
+ )
595
+
596
+ gr.HTML('''
597
+ <div style="text-align: center; padding: 2rem 1rem 1.5rem;">
598
+ <h1 style="font-size: 1.75rem; font-weight: 700; color: #111827;">⚙️ API Keys</h1>
599
+ <p style="color: #6b7280; margin-top: 0.5rem;">Configure your API keys to enable all features</p>
600
+ </div>
601
+ ''')
602
+
603
+ with gr.Row():
604
+ gr.Column(scale=1, min_width=50)
605
+ with gr.Column(scale=2, min_width=400):
606
+ settings_gemini = gr.Textbox(
607
+ label="Google Gemini API Key (required)",
608
+ placeholder="Get from aistudio.google.com/apikey",
609
+ type="password",
610
+ interactive=True
611
+ )
612
+ settings_openai = gr.Textbox(
613
+ label="OpenAI API Key (optional)",
614
+ placeholder="Get from platform.openai.com/api-keys",
615
+ type="password",
616
+ interactive=True
617
+ )
618
+ settings_elevenlabs = gr.Textbox(
619
+ label="ElevenLabs API Key (optional, for audio)",
620
+ placeholder="Get from elevenlabs.io/app/developers/api-keys",
621
+ type="password",
622
+ interactive=True
623
+ )
624
+
625
+ save_btn = gr.Button("💾 Save Settings", variant="primary")
626
+ settings_status = gr.HTML("")
627
+ gr.Column(scale=1, min_width=50)
628
+
629
+ # Settings events
630
+ def load_settings():
631
+ session = load_session_state()
632
+ return (
633
+ session.get("api_key", ""),
634
+ session.get("openai_api_key", ""),
635
+ session.get("elevenlabs_api_key", "")
636
+ )
637
+
638
+ def save_settings(gemini_key, openai_key, elevenlabs_key):
639
+ if save_session_state({
640
+ "api_key": gemini_key,
641
+ "openai_api_key": openai_key,
642
+ "elevenlabs_api_key": elevenlabs_key,
643
+ }):
644
+ # Update config
645
+ config = get_config()
646
+ config.gemini_api_key = gemini_key
647
+ config.openai_api_key = openai_key
648
+ config.elevenlabs_api_key = elevenlabs_key
649
+ return '<p style="color: #059669; text-align: center;">✅ Settings saved!</p>'
650
+ return '<p style="color: #dc2626; text-align: center;">❌ Failed to save</p>'
651
+
652
+ settings_page.load(fn=load_settings, outputs=[settings_gemini, settings_openai, settings_elevenlabs])
653
+ settings_page.load(fn=get_current_model, outputs=[settings_model])
654
+
655
+ save_btn.click(
656
+ fn=save_settings,
657
+ inputs=[settings_gemini, settings_openai, settings_elevenlabs],
658
+ outputs=[settings_status]
659
+ )
660
+
661
+ settings_model.change(
662
+ fn=lambda m: save_session_state({"model": m}),
663
+ inputs=[settings_model]
664
+ )
665
+
666
+ return app, CUSTOM_CSS
src/ui/components.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UI Components
3
+
4
+ Reusable UI components for the Gradio interface.
5
+ """
6
+
7
+ from typing import Optional, Dict
8
+
9
+
10
+ def make_nav_bar(active_page: str) -> str:
11
+ """Generate the top navigation bar HTML.
12
+
13
+ Args:
14
+ active_page: One of 'generate', 'explore', or 'settings'
15
+
16
+ Returns:
17
+ HTML string for the navigation bar
18
+ """
19
+ links = [
20
+ ("generate", "/", "🚀 Generate"),
21
+ ("explore", "/explore", "🔍 Explore"),
22
+ ("settings", "/settings", "⚙️ Settings"),
23
+ ]
24
+
25
+ nav_links = ""
26
+ for page_id, href, label in links:
27
+ active_class = "active" if page_id == active_page else ""
28
+ nav_links += f'<a href="{href}" class="{active_class}">{label}</a>'
29
+
30
+ return f'''
31
+ <div class="top-nav-bar">
32
+ <div class="nav-links">
33
+ {nav_links}
34
+ </div>
35
+ </div>
36
+ '''
37
+
38
+
39
+ def make_loading_html(emoji: str, message: str, submessage: str = "") -> str:
40
+ """Generate a loading animation HTML.
41
+
42
+ Args:
43
+ emoji: Emoji to display with animation
44
+ message: Main loading message
45
+ submessage: Optional sub-message
46
+
47
+ Returns:
48
+ HTML string for loading state
49
+ """
50
+ sub_html = f'<p class="loading-submessage">{submessage}</p>' if submessage else ""
51
+ return f'''
52
+ <div class="loading-container">
53
+ <div class="loading-emoji">{emoji}</div>
54
+ <p class="loading-message">{message}</p>
55
+ {sub_html}
56
+ </div>
57
+ '''
58
+
59
+
60
+ def make_stats_bar(
61
+ repo_name: str = "",
62
+ files_processed: int = 0,
63
+ total_characters: int = 0,
64
+ model_name: str = "",
65
+ node_count: int = 0,
66
+ edge_count: int = 0,
67
+ extra_info: str = ""
68
+ ) -> str:
69
+ """Generate the stats bar HTML.
70
+
71
+ Args:
72
+ repo_name: Repository name
73
+ files_processed: Number of files processed
74
+ total_characters: Total characters analyzed
75
+ model_name: AI model used
76
+ node_count: Number of nodes in diagram
77
+ edge_count: Number of edges in diagram
78
+ extra_info: Additional information to display
79
+
80
+ Returns:
81
+ HTML string for stats bar
82
+ """
83
+ parts = []
84
+
85
+ if repo_name:
86
+ parts.append(f'<span>📦 <strong>{repo_name}</strong></span>')
87
+ if files_processed:
88
+ parts.append(f'<span>📁 <strong>{files_processed}</strong> files</span>')
89
+ if total_characters:
90
+ parts.append(f'<span>📊 <strong>{total_characters:,}</strong> chars</span>')
91
+ if node_count:
92
+ parts.append(f'<span>🔵 <strong>{node_count}</strong> nodes</span>')
93
+ if edge_count:
94
+ parts.append(f'<span>🔗 <strong>{edge_count}</strong> edges</span>')
95
+ if model_name:
96
+ parts.append(f'<span>🤖 <strong>{model_name}</strong></span>')
97
+ if extra_info:
98
+ parts.append(f'<span>{extra_info}</span>')
99
+
100
+ return f'<div class="stats-bar">{"".join(parts)}</div>'
101
+
102
+
103
+ def make_error_html(message: str, emoji: str = "❌", link_href: str = "/", link_text: str = "← Try Again") -> str:
104
+ """Generate error display HTML.
105
+
106
+ Args:
107
+ message: Error message to display
108
+ emoji: Emoji to show
109
+ link_href: Link destination
110
+ link_text: Link text
111
+
112
+ Returns:
113
+ HTML string for error display
114
+ """
115
+ return f'''
116
+ <div class="error-container">
117
+ <div class="error-emoji">{emoji}</div>
118
+ <p class="error-message">{message}</p>
119
+ <a href="{link_href}" class="error-link">{link_text}</a>
120
+ </div>
121
+ '''
122
+
123
+
124
+ def make_empty_state_html(message: str = "No diagram yet.", link_href: str = "/", link_text: str = "Generate one →") -> str:
125
+ """Generate empty state HTML.
126
+
127
+ Args:
128
+ message: Message to display
129
+ link_href: Link destination
130
+ link_text: Link text
131
+
132
+ Returns:
133
+ HTML string for empty state
134
+ """
135
+ return f'''
136
+ <div style="display: flex; align-items: center; justify-content: center;
137
+ min-height: 500px; color: #9ca3af; font-size: 1.1rem;">
138
+ {message} <a href="{link_href}" style="margin-left: 0.5rem; color: #f97316;">{link_text}</a>
139
+ </div>
140
+ '''
141
+
142
+
143
+ def make_hero_section(
144
+ emoji: str = "🏗️",
145
+ title: str = "CodeAtlas",
146
+ subtitle: str = "Visualize any codebase architecture with AI"
147
+ ) -> str:
148
+ """Generate hero section HTML.
149
+
150
+ Args:
151
+ emoji: Main emoji
152
+ title: Title text
153
+ subtitle: Subtitle text
154
+
155
+ Returns:
156
+ HTML string for hero section
157
+ """
158
+ return f'''
159
+ <div class="hero-section">
160
+ <div class="hero-emoji">{emoji}</div>
161
+ <h1 class="hero-title">{title}</h1>
162
+ <p class="hero-subtitle">{subtitle}</p>
163
+ </div>
164
+ '''
165
+
166
+
167
+ def make_footer() -> str:
168
+ """Generate footer HTML.
169
+
170
+ Returns:
171
+ HTML string for footer
172
+ """
173
+ return '''
174
+ <div class="footer">
175
+ Built for MCP's 1st Birthday Hackathon 🎂 ·
176
+ Powered by Gemini + LlamaIndex + ElevenLabs + Modal
177
+ </div>
178
+ '''
src/ui/styles.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UI Styles
3
+
4
+ Custom CSS for the CodeAtlas Gradio interface.
5
+ """
6
+
7
+ CUSTOM_CSS = """
8
+ /* Animation for loading states */
9
+ @keyframes pulse {
10
+ 0%, 100% { transform: scale(1); opacity: 1; }
11
+ 50% { transform: scale(1.1); opacity: 0.8; }
12
+ }
13
+
14
+ @keyframes spin {
15
+ from { transform: rotate(0deg); }
16
+ to { transform: rotate(360deg); }
17
+ }
18
+
19
+ /* Hide Gradio's built-in navigation */
20
+ .nav-holder { display: none !important; }
21
+ footer, header, nav { display: none !important; visibility: hidden !important; height: 0 !important; }
22
+ .route-nav { display: none !important; }
23
+
24
+ /* Remove top spacing */
25
+ html, body { margin: 0 !important; padding: 0 !important; }
26
+ .gradio-container { padding: 0 !important; margin: 0 !important; }
27
+ .main { padding-top: 0 !important; margin-top: 0 !important; }
28
+
29
+ /* Top navigation bar */
30
+ .top-nav-bar {
31
+ display: flex;
32
+ justify-content: flex-start;
33
+ align-items: center;
34
+ padding: 0.75rem 1.5rem;
35
+ background: linear-gradient(to right, #ffffff, #fafafa);
36
+ border-bottom: 1px solid #e5e7eb;
37
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
38
+ }
39
+
40
+ .top-nav-bar .nav-links {
41
+ display: flex;
42
+ gap: 0.25rem;
43
+ align-items: center;
44
+ }
45
+
46
+ .top-nav-bar .nav-links a {
47
+ padding: 0.5rem 1rem;
48
+ border-radius: 8px;
49
+ text-decoration: none;
50
+ color: #4b5563;
51
+ font-weight: 500;
52
+ font-size: 0.9rem;
53
+ transition: all 0.2s ease;
54
+ }
55
+
56
+ .top-nav-bar .nav-links a:hover {
57
+ background: #f3f4f6;
58
+ color: #111827;
59
+ }
60
+
61
+ .top-nav-bar .nav-links a.active {
62
+ background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
63
+ color: white;
64
+ box-shadow: 0 2px 4px rgba(249, 115, 22, 0.3);
65
+ }
66
+
67
+ /* Nav bar row */
68
+ .nav-bar-row {
69
+ display: flex !important;
70
+ flex-direction: row !important;
71
+ align-items: center !important;
72
+ justify-content: space-between !important;
73
+ padding: 0 !important;
74
+ margin: 0 !important;
75
+ background: linear-gradient(to right, #ffffff, #fafafa) !important;
76
+ border-bottom: 1px solid #e5e7eb !important;
77
+ }
78
+
79
+ /* Model dropdown styling */
80
+ .model-dropdown-nav {
81
+ min-width: 200px !important;
82
+ max-width: 240px !important;
83
+ }
84
+
85
+ .model-dropdown-nav label { display: none !important; }
86
+
87
+ .model-dropdown-nav input,
88
+ .model-dropdown-nav button {
89
+ padding: 0.4rem 2rem 0.4rem 0.75rem !important;
90
+ border: 1px solid #e5e7eb !important;
91
+ border-radius: 8px !important;
92
+ background: white !important;
93
+ font-size: 0.85rem !important;
94
+ height: 32px !important;
95
+ }
96
+
97
+ .model-dropdown-nav input:hover,
98
+ .model-dropdown-nav button:hover {
99
+ border-color: #f97316 !important;
100
+ }
101
+
102
+ /* Button styles */
103
+ button.primary, button[class*="primary"] {
104
+ background: linear-gradient(135deg, #f97316 0%, #ea580c 100%) !important;
105
+ border: none !important;
106
+ box-shadow: 0 2px 4px rgba(249, 115, 22, 0.3) !important;
107
+ transition: all 0.2s ease !important;
108
+ }
109
+
110
+ button.primary:hover, button[class*="primary"]:hover {
111
+ box-shadow: 0 4px 8px rgba(249, 115, 22, 0.4) !important;
112
+ transform: translateY(-1px) !important;
113
+ }
114
+
115
+ button.secondary, button[class*="secondary"] {
116
+ border: 1px solid #e5e7eb !important;
117
+ background: white !important;
118
+ color: #374151 !important;
119
+ transition: all 0.2s ease !important;
120
+ }
121
+
122
+ button.secondary:hover, button[class*="secondary"]:hover {
123
+ border-color: #f97316 !important;
124
+ color: #f97316 !important;
125
+ background: #fff7ed !important;
126
+ }
127
+
128
+ /* Form inputs */
129
+ input, textarea, select {
130
+ transition: all 0.2s ease !important;
131
+ }
132
+
133
+ input:focus, textarea:focus {
134
+ border-color: #f97316 !important;
135
+ box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1) !important;
136
+ }
137
+
138
+ /* Diagram container */
139
+ .diagram-box {
140
+ background: white;
141
+ border-radius: 16px;
142
+ padding: 1.5rem;
143
+ overflow: auto;
144
+ min-height: 500px;
145
+ max-height: 80vh;
146
+ box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
147
+ border: 1px solid #f3f4f6;
148
+ }
149
+
150
+ .diagram-inner {
151
+ transition: transform 0.2s ease-out;
152
+ }
153
+
154
+ /* Stats bar */
155
+ .stats-bar {
156
+ display: flex;
157
+ gap: 1.5rem;
158
+ padding: 0.75rem 1.25rem;
159
+ background: linear-gradient(to right, #f9fafb, #ffffff);
160
+ border-radius: 10px;
161
+ margin-bottom: 1rem;
162
+ flex-wrap: wrap;
163
+ border: 1px solid #f3f4f6;
164
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04);
165
+ }
166
+
167
+ .stats-bar span {
168
+ font-size: 0.85rem;
169
+ color: #4b5563;
170
+ }
171
+
172
+ .stats-bar span strong {
173
+ color: #111827;
174
+ }
175
+
176
+ /* Right sidebar */
177
+ .sidebar-right.sidebar {
178
+ height: calc(100vh - 60px) !important;
179
+ }
180
+
181
+ .sidebar-right .sidebar-content {
182
+ display: flex !important;
183
+ flex-direction: column !important;
184
+ height: 100% !important;
185
+ padding: 0.5rem !important;
186
+ gap: 0.5rem !important;
187
+ }
188
+
189
+ /* Audio section */
190
+ .sidebar-right .audio-row {
191
+ flex-shrink: 0 !important;
192
+ }
193
+
194
+ .sidebar-right .audio-player-compact {
195
+ flex-shrink: 0 !important;
196
+ }
197
+
198
+ .sidebar-right .audio-player-compact audio {
199
+ height: 36px !important;
200
+ }
201
+
202
+ .sidebar-right .audio-gen-btn {
203
+ padding: 0.5rem 1.25rem 0.5rem 1rem !important;
204
+ border-radius: 8px !important;
205
+ font-weight: 600 !important;
206
+ font-size: 0.85rem !important;
207
+ }
208
+
209
+ .sidebar-right .audio-status {
210
+ font-size: 0.85rem !important;
211
+ color: #059669 !important;
212
+ margin-top: 0.25rem !important;
213
+ font-weight: 600 !important;
214
+ }
215
+
216
+ /* Chat styling */
217
+ #codeatlas-chat {
218
+ flex: 1 1 auto !important;
219
+ min-height: 0 !important;
220
+ height: 100% !important;
221
+ border: none !important;
222
+ background: transparent !important;
223
+ overflow: hidden !important;
224
+ }
225
+
226
+ #codeatlas-chat .message {
227
+ max-width: 100% !important;
228
+ padding: 0.5rem !important;
229
+ border-radius: 8px !important;
230
+ }
231
+
232
+ #codeatlas-chat .message.user {
233
+ background: #fff7ed !important;
234
+ border: 1px solid #fed7aa !important;
235
+ }
236
+
237
+ #codeatlas-chat .message.bot {
238
+ background: #ffffff !important;
239
+ border: 1px solid #e5e7eb !important;
240
+ }
241
+
242
+ .sidebar-right .chat-input-container {
243
+ flex-shrink: 0 !important;
244
+ padding-top: 0.5rem !important;
245
+ border-top: 1px solid #e5e7eb !important;
246
+ }
247
+
248
+ .chat-input-row {
249
+ display: flex !important;
250
+ gap: 0.5rem !important;
251
+ align-items: center !important;
252
+ }
253
+
254
+ .chat-input-row input,
255
+ .chat-input-row textarea {
256
+ flex: 1 1 auto !important;
257
+ }
258
+
259
+ .chat-input-row button {
260
+ width: 44px !important;
261
+ height: 40px !important;
262
+ padding: 0 !important;
263
+ border-radius: 8px !important;
264
+ }
265
+
266
+ /* Left sidebar */
267
+ .sidebar {
268
+ background: #fafafa !important;
269
+ }
270
+
271
+ .sidebar h4, .sidebar .markdown h4 {
272
+ color: #374151 !important;
273
+ font-size: 0.85rem !important;
274
+ font-weight: 600 !important;
275
+ text-transform: uppercase !important;
276
+ letter-spacing: 0.05em !important;
277
+ }
278
+
279
+ .sidebar hr {
280
+ border-color: #e5e7eb !important;
281
+ margin: 0.75rem 0 !important;
282
+ }
283
+
284
+ /* Loading states */
285
+ .loading-container {
286
+ display: flex;
287
+ flex-direction: column;
288
+ align-items: center;
289
+ justify-content: center;
290
+ min-height: 400px;
291
+ text-align: center;
292
+ }
293
+
294
+ .loading-emoji {
295
+ font-size: 4rem;
296
+ animation: pulse 1.5s ease-in-out infinite;
297
+ }
298
+
299
+ .loading-message {
300
+ font-size: 1.2rem;
301
+ margin-top: 1rem;
302
+ font-weight: 500;
303
+ color: #374151;
304
+ }
305
+
306
+ .loading-submessage {
307
+ color: #6b7280;
308
+ margin-top: 0.5rem;
309
+ font-size: 0.9rem;
310
+ }
311
+
312
+ /* Hero section */
313
+ .hero-section {
314
+ text-align: center;
315
+ padding: 3rem 1rem 2rem 1rem;
316
+ }
317
+
318
+ .hero-emoji {
319
+ font-size: 4rem;
320
+ margin-bottom: 0.75rem;
321
+ filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));
322
+ }
323
+
324
+ .hero-title {
325
+ font-size: 2.25rem;
326
+ font-weight: 700;
327
+ color: #111827;
328
+ margin: 0;
329
+ letter-spacing: -0.02em;
330
+ }
331
+
332
+ .hero-subtitle {
333
+ color: #6b7280;
334
+ margin-top: 0.75rem;
335
+ font-size: 1.05rem;
336
+ font-weight: 400;
337
+ }
338
+
339
+ /* Footer */
340
+ .footer {
341
+ text-align: center;
342
+ color: #9ca3af;
343
+ font-size: 0.8rem;
344
+ margin-top: 4rem;
345
+ padding: 1.5rem;
346
+ border-top: 1px solid #f3f4f6;
347
+ }
348
+
349
+ /* Card styling */
350
+ .card {
351
+ background: white;
352
+ border-radius: 12px;
353
+ border: 1px solid #f3f4f6;
354
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
355
+ padding: 1rem;
356
+ }
357
+
358
+ /* Error styling */
359
+ .error-container {
360
+ display: flex;
361
+ flex-direction: column;
362
+ align-items: center;
363
+ justify-content: center;
364
+ min-height: 400px;
365
+ text-align: center;
366
+ }
367
+
368
+ .error-emoji {
369
+ font-size: 3rem;
370
+ margin-bottom: 1rem;
371
+ }
372
+
373
+ .error-message {
374
+ color: #dc2626;
375
+ font-size: 1.1rem;
376
+ }
377
+
378
+ .error-link {
379
+ margin-top: 1rem;
380
+ color: #f97316;
381
+ }
382
+
383
+ /* Mobile responsiveness */
384
+ @media (max-width: 768px) {
385
+ .top-nav-bar {
386
+ flex-direction: column;
387
+ gap: 0.5rem;
388
+ }
389
+
390
+ .stats-bar {
391
+ flex-direction: column;
392
+ gap: 0.5rem;
393
+ }
394
+
395
+ .hero-title {
396
+ font-size: 1.75rem;
397
+ }
398
+ }
399
+ """