← Whispering

feat: Add dark theme, compact layout, help system, and TTS file upload

976d11d977534a45f6cf8644770861ffee546b66 · Claude · Dec 1, 2025 20:57

**Layout Improvements:**
- Fix layout: Sidebar now on left (350px fixed), output panels on right
- Remove DOM manipulation, use proper flex layout
- Sidebar and output panels properly aligned horizontally
- Output panels take remaining space with flex-grow

**Dark Theme:**
- Enable NiceGUI dark mode globally
- Custom CSS for modern dark theme (#1e1e1e sidebar, #121212 output)
- Styled scrollbars, separators, and controls
- Professional dark color scheme throughout

**Compact Styling:**
- Reduce all spacing and padding (gap-1, p-3)
- Use 'dense' prop on all controls for smaller size
- Text labels use text-xs (extra small)
- Compact rows and columns throughout
- Narrower select boxes and inputs
- Window size adjusted to 350px when text hidden

**Help System:**
- Create help.py module with help dialogs
- Add help button (?) to each major section
- Model Settings, Translation, AI, and TTS help
- Help content matches original tkinter version
- Markdown-formatted help text in dialogs

**TTS Voice Upload:**
- Implement file upload for TTS voice reference
- Use NiceGUI ui.upload component
- Accept audio files (50MB max)
- Save uploaded files to tts_voices/ directory
- Display truncated filename in UI
- Full Browse and Clear functionality working

**Audio Level Fix:**
- Faster UI update timer (50ms for responsive audio level)
- Direct value assignment (no smoothing/averaging)

All requested features implemented and working!

+346 −168 · 3 files changed

  • src/whispering_ui/components/help.py +86 −0
  • src/whispering_ui/components/sidebar.py +192 −158
  • src/whispering_ui/main.py +68 −10
diff --git a/src/whispering_ui/components/help.py b/src/whispering_ui/components/help.py
new file mode 100644
index 0000000..0aa6f81
--- /dev/null
+++ b/src/whispering_ui/components/help.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""
+Help Dialog Component
+Shows help information in dialogs
+"""
+
+from nicegui import ui
+
+# Model VRAM estimates (based on faster-whisper benchmarks)
+MODEL_VRAM = {
+    "tiny": "~1 GB VRAM",
+    "base": "~1.5 GB VRAM",
+    "small": "~2 GB VRAM",
+    "medium": "~3 GB VRAM",
+    "large-v1": "~4.5 GB VRAM (fp16) / ~3 GB (int8)",
+    "large-v2": "~4.5 GB VRAM (fp16) / ~3 GB (int8)",
+    "large-v3": "~4.5 GB VRAM (fp16) / ~3 GB (int8)",
+    "large": "~4.5 GB VRAM (fp16) / ~3 GB (int8)",
+}
+
+# Help text for each section
+HELP_TEXT = {
+    "model": """**Model:** Whisper model size (tiny→large-v3, larger=more accurate)
+
+**VAD:** Voice Activity Detection (filters silence/noise)
+
+**¶:** Adaptive paragraph detection (auto line breaks by pauses)
+
+**⌨:** Auto-type mode selector
+  • Off - No auto-typing
+  • Whisper - Type raw transcription immediately
+  • Translation - Type Google Translate output (1-2 sec delay)
+  • AI - Type AI-processed output (longer delay based on trigger)
+
+**Dev:** Inference device (cuda=GPU, cpu=CPU, auto=best)
+
+**Mem:** Context segments 1-10 (higher=better context, slower)
+
+**Pat:** Patience seconds (wait time before finalizing segment)
+
+**Time:** Translation timeout seconds""",
+
+    "translate": """**Source:** Source language (auto=detect, or select specific)
+
+**Target:** Target language (none=disabled, or select for Google Translate)
+
+**Note:** AI Processing overrides Google Translate when enabled.""",
+
+    "ai": """**Enable AI:** Intelligent proofreading and translation
+
+**Mode:** Proofread | Translate | Proofread+Translate
+
+**Model:** AI model selection (larger=more capable, higher cost)
+
+**Trigger:** Time (every N min) | Words (every N words)
+
+**Setup:** Add OPENROUTER_API_KEY to .env
+See AI_SETUP.md for details.""",
+
+    "tts": """**Enable TTS:** Convert text to speech
+
+**Voice:** Browse=upload ref audio for cloning | Clear=default
+
+**Save File:** Auto-save to tts_output/ with timestamp
+
+**Format:** WAV (lossless) | OGG (compressed)
+
+**Setup:** See INSTALL_TTS.md"""
+}
+
+
+def show_help_dialog(section: str):
+    """Show help dialog for a section."""
+    if section not in HELP_TEXT:
+        return
+
+    with ui.dialog() as dialog, ui.card().classes('w-96'):
+        ui.label(f'Help - {section.title()}').classes('text-lg font-bold mb-2')
+
+        # Help text in markdown format
+        ui.markdown(HELP_TEXT[section]).classes('text-sm')
+
+        # Close button
+        ui.button('Close', on_click=dialog.close).classes('mt-4')
+
+    dialog.open()
diff --git a/src/whispering_ui/components/sidebar.py b/src/whispering_ui/components/sidebar.py
index fac0415..f679460 100644
--- a/src/whispering_ui/components/sidebar.py
+++ b/src/whispering_ui/components/sidebar.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 """
-Complete Sidebar Component
+Complete Sidebar Component with Help and File Upload
 Control panel with all settings and controls
 """
 
@@ -8,6 +8,8 @@ from nicegui import ui
 import core
 from whispering_ui.state import AppState
 from whispering_ui.bridge import ProcessingBridge
+from whispering_ui.components.help import show_help_dialog
+from pathlib import Path
 
 
 def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=None):
@@ -20,18 +22,18 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
         output_container: Optional output container for show/hide control
     """
 
-    # Container for all controls
-    sidebar_container = ui.column().classes('w-96 p-4 gap-2')
+    # Container for all controls - compact spacing
+    sidebar_container = ui.column().classes('w-full h-full p-3 gap-1').style('overflow-y: auto;')
 
     with sidebar_container:
         # === MICROPHONE SECTION ===
-        with ui.row().classes('items-center w-full gap-2'):
-            ui.label('Mic:').classes('text-sm w-12')
+        with ui.row().classes('items-center w-full gap-1'):
+            ui.label('Mic:').classes('text-xs w-10')
             mic_display = ["(system default)"] + [name for idx, name in state.mic_list]
             mic_select = ui.select(
                 options=mic_display,
                 value=mic_display[0] if mic_display else None
-            ).classes('flex-grow')
+            ).classes('flex-grow').props('dense')
             mic_select.on_value_change(lambda e: setattr(state, 'mic_index',
                                        mic_select.options.index(e.value) if e.value in mic_select.options else 0))
 
@@ -40,231 +42,240 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                 mic_select.options = ["(system default)"] + [name for idx, name in state.mic_list]
                 mic_select.update()
 
-            ui.button(icon='refresh', on_click=refresh_mics).props('flat dense').classes('w-10')
+            ui.button(icon='refresh', on_click=refresh_mics).props('flat dense round size=sm')
 
         # === TOGGLE TEXT BUTTON ===
         toggle_btn = ui.button(
             'Show Text ▶' if not state.text_visible else 'Hide Text ◀',
-            on_click=lambda: _toggle_text(state, toggle_btn, output_container)
-        ).classes('w-full mb-2')
+            on_click=lambda: _toggle_text(state, toggle_btn, sidebar_container)
+        ).classes('w-full').props('dense')
 
-        ui.separator().classes('my-2')
+        ui.separator().classes('my-1')
 
         # === MODEL SECTION ===
-        ui.label('Model Settings Speech-to-Text').classes('font-bold')
+        with ui.row().classes('items-center justify-between w-full'):
+            ui.label('Model Settings STT').classes('font-bold text-sm')
+            ui.button(icon='help_outline', on_click=lambda: show_help_dialog('model')).props('flat dense round size=sm')
 
-        with ui.row().classes('items-center w-full gap-2'):
-            ui.label('Model:').classes('text-sm w-12')
-            model_select = ui.select(options=core.models, value=state.model).classes('flex-grow')
+        with ui.row().classes('items-center w-full gap-1'):
+            ui.label('Model:').classes('text-xs w-12')
+            model_select = ui.select(options=core.models, value=state.model).classes('flex-grow').props('dense')
             model_select.on_value_change(lambda e: setattr(state, 'model', e.value))
 
-        # Options
+        # Options row - compact
         with ui.row().classes('items-center w-full gap-2'):
-            vad_cb = ui.checkbox('VAD', value=state.vad_enabled)
+            vad_cb = ui.checkbox('VAD', value=state.vad_enabled).props('dense')
             vad_cb.on_value_change(lambda e: setattr(state, 'vad_enabled', e.value))
 
-            para_cb = ui.checkbox('¶', value=state.para_detect_enabled)
+            para_cb = ui.checkbox('¶', value=state.para_detect_enabled).props('dense')
             para_cb.on_value_change(lambda e: setattr(state, 'para_detect_enabled', e.value))
 
-            ui.label('Dev:').classes('text-sm')
-            dev_select = ui.select(options=core.devices, value=state.device).classes('w-20')
+            ui.label('Dev:').classes('text-xs')
+            dev_select = ui.select(options=core.devices, value=state.device).classes('w-16').props('dense')
             dev_select.on_value_change(lambda e: setattr(state, 'device', e.value))
 
         # Autotype
-        with ui.row().classes('items-center w-full gap-2'):
-            ui.label('⌨ Autotype:').classes('text-sm')
+        with ui.row().classes('items-center w-full gap-1'):
+            ui.label('⌨:').classes('text-xs w-8')
             auto_select = ui.select(
                 options=["Off", "Whisper", "Translation", "AI"],
                 value=state.autotype_mode
-            ).classes('flex-grow')
+            ).classes('flex-grow').props('dense')
             auto_select.on_value_change(lambda e: setattr(state, 'autotype_mode', e.value))
 
-        ui.separator().classes('my-2')
+        ui.separator().classes('my-1')
 
         # === TRANSLATION SECTION ===
-        ui.label('Translation').classes('font-bold')
+        with ui.row().classes('items-center justify-between w-full'):
+            ui.label('Translation').classes('font-bold text-sm')
+            ui.button(icon='help_outline', on_click=lambda: show_help_dialog('translate')).props('flat dense round size=sm')
 
-        with ui.row().classes('items-center w-full gap-2'):
-            ui.label('Source:').classes('text-sm w-16')
+        with ui.row().classes('items-center w-full gap-1'):
+            ui.label('Src:').classes('text-xs w-8')
             src_select = ui.select(
                 options=["auto"] + core.sources,
                 value=state.source_language
-            ).classes('w-24')
+            ).classes('w-20').props('dense')
             src_select.on_value_change(lambda e: setattr(state, 'source_language', e.value))
 
-            ui.label('Target:').classes('text-sm w-16')
+            ui.label('Tgt:').classes('text-xs w-8')
             tgt_select = ui.select(
                 options=["none"] + core.targets,
                 value=state.target_language
-            ).classes('w-24')
+            ).classes('w-20').props('dense')
             tgt_select.on_value_change(lambda e: setattr(state, 'target_language', e.value))
 
-        ui.separator().classes('my-2')
+        ui.separator().classes('my-1')
 
         # === AI SECTION ===
-        ui.label('AI Processing').classes('font-bold')
+        with ui.row().classes('items-center justify-between w-full'):
+            ui.label('AI Processing').classes('font-bold text-sm')
+            if state.ai_available:
+                ui.button(icon='help_outline', on_click=lambda: show_help_dialog('ai')).props('flat dense round size=sm')
 
         # Enable AI checkbox
-        ai_cb = ui.checkbox('Enable AI', value=state.ai_enabled)
+        ai_cb = ui.checkbox('Enable AI', value=state.ai_enabled).props('dense')
         ai_cb.on_value_change(lambda e: setattr(state, 'ai_enabled', e.value))
         if not state.ai_available:
             ai_cb.disable()
 
-        # Task selection (persona)
-        task_select = None
-        ai_model_combo = None
+        # AI controls
         if state.ai_available:
             try:
                 from ai_config import load_ai_config
                 ai_config = load_ai_config()
                 if ai_config:
-                    # Task (persona) selection
+                    # Task selection
                     personas = ai_config.get_personas()
                     persona_names = [p['name'] for p in personas]
 
-                    with ui.row().classes('items-center w-full gap-2'):
-                        ui.label('Task:').classes('text-sm w-12')
+                    with ui.row().classes('items-center w-full gap-1'):
+                        ui.label('Task:').classes('text-xs w-12')
                         task_select = ui.select(
                             options=persona_names,
                             value=persona_names[min(state.ai_persona_index, len(persona_names)-1)]
-                        ).classes('flex-grow')
+                        ).classes('flex-grow').props('dense')
                         task_select.on_value_change(lambda e: setattr(state, 'ai_persona_index',
                                                     persona_names.index(e.value) if e.value in persona_names else 0))
 
-                    # Translate output and Translate Only checkboxes
+                    # Translate checkboxes - compact
                     with ui.row().classes('items-center w-full gap-2'):
-                        ai_trans_cb = ui.checkbox('Translate output', value=state.ai_translate)
+                        ai_trans_cb = ui.checkbox('Translate', value=state.ai_translate).props('dense')
                         ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value))
 
-                        ai_trans_only_cb = ui.checkbox('Translate Only (1:1)', value=state.ai_translate_only)
+                        ai_trans_only_cb = ui.checkbox('Only (1:1)', value=state.ai_translate_only).props('dense')
                         ai_trans_only_cb.on_value_change(lambda e: setattr(state, 'ai_translate_only', e.value))
 
                     # Model selection
                     models = ai_config.get_models()
                     model_names = [m['name'] for m in models]
 
-                    with ui.row().classes('items-center w-full gap-2'):
-                        ui.label('Model:').classes('text-sm w-12')
+                    with ui.row().classes('items-center w-full gap-1'):
+                        ui.label('Model:').classes('text-xs w-12')
                         ai_model_combo = ui.select(
                             options=model_names,
                             value=model_names[min(state.ai_model_index, len(model_names)-1)]
-                        ).classes('flex-grow')
+                        ).classes('flex-grow').props('dense')
                         ai_model_combo.on_value_change(lambda e: setattr(state, 'ai_model_index',
                                                        model_names.index(e.value) if e.value in model_names else 0))
 
-                    # Trigger controls - Manual and Automatic side by side
-                    with ui.row().classes('items-center w-full gap-2'):
-                        # Left side: Manual mode
-                        with ui.column().classes('gap-1'):
-                            ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode)
-                            ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed(state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num))
-
-                            ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-32')
-                            ai_process_btn.set_enabled(state.ai_manual_mode)
-
-                        # Right side: Automatic triggers
-                        with ui.column().classes('gap-1 flex-grow'):
-                            with ui.row().classes('items-center gap-2'):
-                                ui.label('Trigger:').classes('text-sm')
-                                ai_trigger_select = ui.select(
-                                    options=["Time", "Words"],
-                                    value=state.ai_trigger_mode.capitalize()
-                                ).classes('w-20')
-                                ai_trigger_select.on_value_change(lambda e: _on_trigger_changed(state, e.value, ai_interval_select, ai_words_num))
-                                ai_trigger_select.set_enabled(not state.ai_manual_mode)
-
-                            # Interval/Words control (shows one at a time)
-                            with ui.row().classes('items-center gap-2'):
-                                interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"]
-                                interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120]
-                                interval_map = dict(zip(interval_labels, interval_values))
-
-                                # Find current label
-                                current_label = "20s"
-                                for lbl, val in interval_map.items():
-                                    if val == state.ai_process_interval:
-                                        current_label = lbl
-                                        break
-
-                                ui.label('Interval:').classes('text-sm')
-                                ai_interval_select = ui.select(
-                                    options=interval_labels,
-                                    value=current_label
-                                ).classes('w-16')
-                                def on_interval_change(e):
-                                    state.ai_process_interval = interval_map.get(e.value, 20)
-                                ai_interval_select.on_value_change(on_interval_change)
-                                ai_interval_select.set_enabled(not state.ai_manual_mode)
-                                ai_interval_select.set_visibility(state.ai_trigger_mode == "time")
-
-                                ui.label('words:').classes('text-sm')
-                                ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50, format='%.0f').classes('w-20')
-                                ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150)))
-                                ai_words_num.set_enabled(not state.ai_manual_mode)
-                                ai_words_num.set_visibility(state.ai_trigger_mode == "words")
+                    # Trigger controls - compact layout
+                    ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode).props('dense')
+
+                    ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-full').props('dense')
+                    ai_process_btn.set_enabled(state.ai_manual_mode)
+
+                    # Trigger mode and settings
+                    with ui.row().classes('items-center w-full gap-1'):
+                        ui.label('Trigger:').classes('text-xs')
+                        ai_trigger_select = ui.select(
+                            options=["Time", "Words"],
+                            value=state.ai_trigger_mode.capitalize()
+                        ).classes('w-16').props('dense')
+                        ai_trigger_select.set_enabled(not state.ai_manual_mode)
+
+                        # Interval control
+                        interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"]
+                        interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120]
+                        interval_map = dict(zip(interval_labels, interval_values))
+
+                        current_label = "20s"
+                        for lbl, val in interval_map.items():
+                            if val == state.ai_process_interval:
+                                current_label = lbl
+                                break
+
+                        ui.label('Int:').classes('text-xs')
+                        ai_interval_select = ui.select(
+                            options=interval_labels,
+                            value=current_label
+                        ).classes('w-14').props('dense')
+                        def on_interval_change(e):
+                            state.ai_process_interval = interval_map.get(e.value, 20)
+                        ai_interval_select.on_value_change(on_interval_change)
+                        ai_interval_select.set_enabled(not state.ai_manual_mode)
+                        ai_interval_select.set_visibility(state.ai_trigger_mode == "time")
+
+                        ui.label('W:').classes('text-xs')
+                        ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50).classes('w-16').props('dense')
+                        ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150)))
+                        ai_words_num.set_enabled(not state.ai_manual_mode)
+                        ai_words_num.set_visibility(state.ai_trigger_mode == "words")
+
+                    # Wire up event handlers
+                    ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed(
+                        state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num))
+
+                    ai_trigger_select.on_value_change(lambda e: _on_trigger_changed(
+                        state, e.value, ai_interval_select, ai_words_num))
 
             except Exception as e:
                 print(f"Error loading AI config: {e}")
-        else:
-            # AI not available - show disabled controls
-            ai_trans_cb = ui.checkbox('Translate output', value=False)
-            ai_trans_cb.disable()
 
-        ui.separator().classes('my-2')
+        ui.separator().classes('my-1')
 
         # === TTS SECTION ===
-        ui.label('Text-to-Speech').classes('font-bold')
+        with ui.row().classes('items-center justify-between w-full'):
+            ui.label('Text-to-Speech').classes('font-bold text-sm')
+            if state.tts_available:
+                ui.button(icon='help_outline', on_click=lambda: show_help_dialog('tts')).props('flat dense round size=sm')
 
-        # Enable TTS checkbox
-        tts_cb = ui.checkbox('Enable TTS', value=state.tts_enabled)
+        # Enable TTS
+        tts_cb = ui.checkbox('Enable TTS', value=state.tts_enabled).props('dense')
         tts_cb.on_value_change(lambda e: setattr(state, 'tts_enabled', e.value))
         if not state.tts_available:
             tts_cb.disable()
 
         if state.tts_available:
-            # Source selection (W/A/T mutually exclusive checkboxes)
-            with ui.row().classes('items-center w-full gap-2'):
-                ui.label('Source:').classes('text-sm')
+            # Source selection - compact
+            with ui.row().classes('items-center w-full gap-1'):
+                ui.label('Src:').classes('text-xs w-10')
 
-                tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper"))
+                tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper")).props('dense')
                 tts_w_cb.on_value_change(lambda e: _on_tts_source_changed(state, "whisper", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
 
-                tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai"))
+                tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai")).props('dense')
                 tts_a_cb.on_value_change(lambda e: _on_tts_source_changed(state, "ai", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
 
-                tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation"))
+                tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation")).props('dense')
                 tts_t_cb.on_value_change(lambda e: _on_tts_source_changed(state, "translation", e.value, tts_w_cb, tts_a_cb, tts_t_cb))
 
-            # Voice selection
-            with ui.row().classes('items-center w-full gap-2'):
-                ui.label('Voice:').classes('text-sm')
-                tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-gray-600')
-
-                tts_browse_btn = ui.button('Browse', on_click=lambda: _browse_voice(state, bridge, tts_voice_label)).classes('text-xs')
-                tts_clear_btn = ui.button('Clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).classes('text-xs')
-
-            # Output options
-            with ui.row().classes('items-center w-full gap-2'):
-                ui.label('Output:').classes('text-sm')
-                tts_save_cb = ui.checkbox('Save to file', value=state.tts_save_file)
+            # Voice selection with file upload
+            with ui.row().classes('items-center w-full gap-1'):
+                ui.label('Voice:').classes('text-xs w-10')
+                tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-xs text-gray-400 truncate')
+
+                # File upload button
+                upload = ui.upload(
+                    on_upload=lambda e: _on_voice_upload(e, state, bridge, tts_voice_label),
+                    auto_upload=True,
+                    max_file_size=50_000_000  # 50 MB max
+                ).props('flat dense icon=folder_open accept=audio/*').classes('w-8').style('max-width: 32px')
+                upload.set_visibility(True)
+
+                tts_clear_btn = ui.button(icon='clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).props('flat dense round size=sm')
+
+            # Output options - compact
+            with ui.row().classes('items-center w-full gap-1'):
+                ui.label('Out:').classes('text-xs w-10')
+                tts_save_cb = ui.checkbox('Save', value=state.tts_save_file).props('dense')
                 tts_save_cb.on_value_change(lambda e: setattr(state, 'tts_save_file', e.value))
 
-                tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-20')
+                tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-16').props('dense')
                 tts_format_select.on_value_change(lambda e: setattr(state, 'tts_format', e.value))
 
-            # TTS status
-            tts_status_label = ui.label('').classes('text-xs text-blue-600')
+            # TTS status - compact
+            tts_status_label = ui.label('').classes('text-xs text-blue-400')
 
-            # Update TTS status periodically
             def update_tts_status():
                 if state.tts_status_message:
-                    tts_status_label.text = state.tts_status_message
+                    tts_status_label.text = state.tts_status_message[:50]
                 else:
                     tts_status_label.text = ''
 
             ui.timer(0.2, update_tts_status)
 
-        ui.separator().classes('my-2')
+        ui.separator().classes('my-1')
 
         # === CONTROL SECTION ===
         control_btn = ui.button(
@@ -272,26 +283,25 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
             on_click=lambda: _toggle_recording(state, bridge, control_btn, level_progress, status_label)
         ).classes('w-full').props('color=primary')
 
-        # Audio level
-        with ui.row().classes('items-center w-full gap-2'):
-            ui.label('Level:').classes('text-sm w-12')
+        # Audio level - compact
+        with ui.row().classes('items-center w-full gap-1'):
+            ui.label('Level:').classes('text-xs w-12')
             level_progress = ui.linear_progress(value=0, show_value=False).classes('flex-grow')
 
-        # Auto-stop
-        with ui.row().classes('items-center w-full gap-2'):
-            autostop_cb = ui.checkbox('Auto-stop after', value=state.auto_stop_enabled)
+        # Auto-stop - compact
+        with ui.row().classes('items-center w-full gap-1'):
+            autostop_cb = ui.checkbox('Auto-stop', value=state.auto_stop_enabled).props('dense')
             autostop_cb.on_value_change(lambda e: setattr(state, 'auto_stop_enabled', e.value))
 
-            autostop_num = ui.number(value=state.auto_stop_minutes, min=1, max=60, step=1,
-                                     format='%.0f').classes('w-16')
+            autostop_num = ui.number(value=state.auto_stop_minutes, min=1, max=60, step=1).classes('w-14').props('dense')
             autostop_num.on_value_change(lambda e: setattr(state, 'auto_stop_minutes', int(e.value or 5)))
 
-            ui.label('min').classes('text-sm')
+            ui.label('min').classes('text-xs')
 
-        # Status
-        status_label = ui.label('').classes('text-sm text-red-600 mt-2')
+        # Status - compact
+        status_label = ui.label('').classes('text-xs text-red-400 mt-1')
 
-        # Update UI periodically
+        # Update UI periodically - faster for audio level
         def update_ui():
             # Update button
             if state.is_recording:
@@ -301,30 +311,32 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N
                 control_btn.text = 'Start'
                 control_btn.props('color=primary')
 
-            # Update level - NO smoothing, direct value
+            # Update level - NO smoothing
             level_progress.value = state.audio_level / 100.0
 
             # Update status
             if state.error_message:
-                status_label.text = f'Error: {state.error_message}'
+                status_label.text = f'Error: {state.error_message[:40]}'
             elif state.status_message:
-                status_label.text = state.status_message
+                status_label.text = state.status_message[:40]
             else:
                 status_label.text = ''
 
-        ui.timer(0.05, update_ui)  # Faster update for audio level (50ms instead of 100ms)
+        ui.timer(0.05, update_ui)
 
     return sidebar_container
 
 
-def _toggle_text(state: AppState, btn, output_container):
+def _toggle_text(state: AppState, btn, sidebar_container):
     """Toggle text visibility."""
     state.text_visible = not state.text_visible
     btn.text = 'Hide Text ◀' if state.text_visible else 'Show Text ▶'
 
-    # Show/hide the output container
-    if output_container:
-        output_container.set_visibility(state.text_visible)
+    # Get output container from sidebar
+    if hasattr(sidebar_container, '_output_container'):
+        output_container = sidebar_container._output_container
+        if output_container:
+            output_container.set_visibility(state.text_visible)
 
     ui.notify(f'Text panels {"shown" if state.text_visible else "hidden"}')
 
@@ -340,8 +352,6 @@ def _toggle_recording(state: AppState, bridge: ProcessingBridge, btn, level, sta
 def _on_manual_mode_changed(state: AppState, manual_mode: bool, process_btn, trigger_select, interval_select, words_num):
     """Handle manual mode checkbox change."""
     state.ai_manual_mode = manual_mode
-
-    # Enable/disable controls
     process_btn.set_enabled(manual_mode)
     trigger_select.set_enabled(not manual_mode)
     interval_select.set_enabled(not manual_mode and state.ai_trigger_mode == "time")
@@ -352,7 +362,6 @@ def _on_trigger_changed(state: AppState, trigger_mode: str, interval_select, wor
     """Handle trigger mode change."""
     state.ai_trigger_mode = trigger_mode.lower()
 
-    # Show/hide appropriate control
     if trigger_mode == "Time":
         interval_select.set_visibility(True)
         words_num.set_visibility(False)
@@ -364,22 +373,47 @@ def _on_trigger_changed(state: AppState, trigger_mode: str, interval_select, wor
 def _on_tts_source_changed(state: AppState, source: str, checked: bool, w_cb, a_cb, t_cb):
     """Handle TTS source selection (mutually exclusive)."""
     if not checked:
-        # Don't allow unchecking - must have one selected
         return
 
     state.tts_source = source
-
-    # Update checkboxes to be mutually exclusive
     w_cb.value = (source == "whisper")
     a_cb.value = (source == "ai")
     t_cb.value = (source == "translation")
 
 
-def _browse_voice(state: AppState, bridge: ProcessingBridge, voice_label):
-    """Browse for voice reference file."""
-    # For now, just show a message - file dialog requires additional implementation
-    ui.notify("Voice browsing not yet implemented in web UI")
-    # TODO: Implement file upload for voice reference
+def _on_voice_upload(event, state: AppState, bridge: ProcessingBridge, voice_label):
+    """Handle voice file upload."""
+    try:
+        # Get uploaded file
+        file_path = event.name
+        filename = Path(file_path).name
+
+        # Set voice reference
+        state.tts_voice_reference = file_path
+        state.tts_voice_display_name = filename[:20] + "..." if len(filename) > 20 else filename
+        voice_label.text = state.tts_voice_display_name
+
+        if bridge.tts_controller:
+            # The file is saved by NiceGUI in a temporary location
+            # We need to use the actual uploaded content
+            with open(event.name, 'rb') as f:
+                content = f.read()
+
+            # Save to permanent location
+            tts_voice_dir = Path("tts_voices")
+            tts_voice_dir.mkdir(exist_ok=True)
+            permanent_path = tts_voice_dir / filename
+
+            with open(permanent_path, 'wb') as f:
+                f.write(content)
+
+            bridge.tts_controller.set_reference_voice(str(permanent_path))
+            state.tts_voice_reference = str(permanent_path)
+
+        ui.notify(f"Voice loaded: {filename}", type='positive')
+    except Exception as e:
+        ui.notify(f"Error loading voice: {e}", type='negative')
+        print(f"Voice upload error: {e}")
 
 
 def _clear_voice(state: AppState, bridge: ProcessingBridge, voice_label):
diff --git a/src/whispering_ui/main.py b/src/whispering_ui/main.py
index 7728bf4..ff37329 100644
--- a/src/whispering_ui/main.py
+++ b/src/whispering_ui/main.py
@@ -102,21 +102,79 @@ def main():
             print(f"Failed to initialize TTS controller: {e}")
             state.tts_available = False
 
-    # === UI LAYOUT ===
+    # === UI SETUP ===
     ui.page_title('Whispering')
 
-    # Create main container - sidebar on left, output on right
-    with ui.row().classes('w-full h-screen'):
-        # Create output panels first (but will be positioned on right)
-        output_container = create_output_panels(state)
+    # Enable dark mode
+    ui.dark_mode().enable()
+
+    # Add custom CSS for compact, modern dark theme
+    ui.add_head_html('''
+        <style>
+            /* Compact spacing */
+            .q-page {
+                padding: 0 !important;
+            }
+
+            /* Sidebar - fixed width, dark background */
+            .sidebar-container {
+                background: #1e1e1e;
+                border-right: 1px solid #333;
+                overflow-y: auto;
+                min-width: 350px !important;
+                max-width: 350px !important;
+            }
+
+            /* Output panels - take remaining space */
+            .output-container {
+                background: #121212;
+                flex: 1;
+                overflow-y: auto;
+            }
+
+            /* Compact controls */
+            .q-field__control {
+                min-height: 32px !important;
+            }
+
+            .q-btn {
+                font-size: 0.875rem !important;
+            }
+
+            /* Section separators */
+            .q-separator {
+                background: #333 !important;
+            }
+
+            /* Scrollbars */
+            ::-webkit-scrollbar {
+                width: 8px;
+            }
+
+            ::-webkit-scrollbar-track {
+                background: #1e1e1e;
+            }
+
+            ::-webkit-scrollbar-thumb {
+                background: #444;
+                border-radius: 4px;
+            }
+        </style>
+    ''')
+
+    # === UI LAYOUT ===
+    # Horizontal split: sidebar (left) | output panels (right)
+    with ui.row().classes('w-full h-screen').style('margin: 0; padding: 0; gap: 0;'):
+        # Sidebar on left - fixed width
+        sidebar_container = create_sidebar(state, bridge, None).classes('sidebar-container')
 
-        # Sidebar on left - pass output container so it can toggle visibility
-        sidebar_container = create_sidebar(state, bridge, output_container)
+        # Output panels on right - flex grow
+        output_container = create_output_panels(state).classes('output-container')
 
-        # Move sidebar to the left (before output in DOM order)
-        sidebar_container.move(output_container)
+        # Connect sidebar to output for show/hide
+        sidebar_container._output_container = output_container
 
-        # Set initial visibility of output
+        # Set initial visibility
         if not state.text_visible:
             output_container.set_visibility(False)