From eed52135e7d6ffbbbd64070cf567bcf08653c7d5 Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Wed, 14 Jan 2026 18:15:31 +0800
Subject: feat: Enhance UI components and add visual effects

- Updated Sidebar component styles for improved aesthetics and usability.
- Refactored VersionsView component with a new layout and enhanced version filtering.
- Improved DownloadMonitor and GameConsole components for better performance and visual consistency.
- Added new settings for GPU acceleration and visual effects in settings store.
- Introduced ParticleBackground component with customizable effects (Constellation and Saturn).
- Implemented ConstellationEffect and SaturnEffect classes for dynamic background animations.
---
 ui/src/lib/GameConsole.svelte | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'ui/src/lib/GameConsole.svelte')

diff --git a/ui/src/lib/GameConsole.svelte b/ui/src/lib/GameConsole.svelte
index 281dc85..8d5e0ce 100644
--- a/ui/src/lib/GameConsole.svelte
+++ b/ui/src/lib/GameConsole.svelte
@@ -75,7 +75,7 @@
 </script>
 
 {#if visible}
-<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700 backdrop-blur flex flex-col z-50 transition-transform duration-300 transform translate-y-0">
+<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700  flex flex-col z-50 transition-transform duration-300 transform translate-y-0">
   <div class="flex items-center justify-between px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
     <div class="flex items-center gap-4">
       <span class="text-xs font-bold text-zinc-400 uppercase tracking-widest">Logs</span>
-- 
cgit v1.2.3-70-g09d2


From 485e8290e1ceaaeb82c7decd1330c8ca1b1f28bd Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Thu, 15 Jan 2026 16:57:20 +0800
Subject: feat: Enhance GameConsole with advanced log filtering, searching, and
 export/upload functionalities for improved user experience

---
 ui/src/lib/GameConsole.svelte | 366 ++++++++++++++++++++++++++++++++----------
 1 file changed, 281 insertions(+), 85 deletions(-)

(limited to 'ui/src/lib/GameConsole.svelte')

diff --git a/ui/src/lib/GameConsole.svelte b/ui/src/lib/GameConsole.svelte
index 8d5e0ce..1b1ab53 100644
--- a/ui/src/lib/GameConsole.svelte
+++ b/ui/src/lib/GameConsole.svelte
@@ -1,107 +1,303 @@
 <script lang="ts">
-  import { listen } from "@tauri-apps/api/event";
-  import { onMount, onDestroy } from "svelte";
+  import { logsState, type LogEntry } from "../stores/logs.svelte";
+  import { uiState } from "../stores/ui.svelte";
+  import { save } from "@tauri-apps/plugin-dialog";
+  import { writeTextFile } from "@tauri-apps/plugin-fs";
+  import { invoke } from "@tauri-apps/api/core";
+  import { open } from "@tauri-apps/plugin-shell";
+  import { onMount, tick } from "svelte";
 
-  export let visible = false;
-
-  let logs: { type: 'stdout' | 'stderr' | 'launcher', line: string, timestamp: string }[] = [];
   let consoleElement: HTMLDivElement;
-  let unlistenStdout: () => void;
-  let unlistenStderr: () => void;
-  let unlistenLauncher: () => void;
-  let unlistenGameExited: () => void;
-
-  function getTimestamp(): string {
-    const now = new Date();
-    return now.toTimeString().split(' ')[0]; // HH:MM:SS
-  }
+  let autoScroll = $state(true);
+  
+  // Search & Filter
+  let searchQuery = $state("");
+  let showInfo = $state(true);
+  let showWarn = $state(true);
+  let showError = $state(true);
+  let showDebug = $state(false);
+  
+  // Source filter: "all" or specific source name
+  let selectedSource = $state("all");
 
-  onMount(async () => {
-    // Listen for launcher logs (preparation, downloads, launch status)
-    unlistenLauncher = await listen<string>("launcher-log", (event) => {
-      addLog('launcher', event.payload);
-    });
-
-    // Listen for game stdout
-    unlistenStdout = await listen<string>("game-stdout", (event) => {
-      addLog('stdout', event.payload);
-    });
-
-    // Listen for game stderr
-    unlistenStderr = await listen<string>("game-stderr", (event) => {
-      addLog('stderr', event.payload);
-    });
-
-    // Listen for game exit event
-    unlistenGameExited = await listen<number>("game-exited", (event) => {
-      addLog('launcher', `Game process exited with code: ${event.payload}`);
-    });
-  });
+  // Get sorted sources for dropdown
+  let sortedSources = $derived([...logsState.sources].sort());
 
-  onDestroy(() => {
-    if (unlistenLauncher) unlistenLauncher();
-    if (unlistenStdout) unlistenStdout();
-    if (unlistenStderr) unlistenStderr();
-    if (unlistenGameExited) unlistenGameExited();
-  });
+  // Derived filtered logs
+  let filteredLogs = $derived(logsState.logs.filter((log) => {
+    // Source Filter
+    if (selectedSource !== "all" && log.source !== selectedSource) return false;
 
-  function addLog(type: 'stdout' | 'stderr' | 'launcher', line: string) {
-    logs = [...logs, { type, line, timestamp: getTimestamp() }];
-    if (logs.length > 2000) {
-      logs = logs.slice(logs.length - 2000);
+    // Level Filter
+    if (!showInfo && log.level === "info") return false;
+    if (!showWarn && log.level === "warn") return false;
+    if (!showError && (log.level === "error" || log.level === "fatal")) return false;
+    if (!showDebug && log.level === "debug") return false;
+
+    // Search Filter
+    if (searchQuery) {
+      const q = searchQuery.toLowerCase();
+      return (
+        log.message.toLowerCase().includes(q) ||
+        log.source.toLowerCase().includes(q)
+      );
     }
-    // Auto-scroll
-    setTimeout(() => {
-        if (consoleElement) {
+    return true;
+  }));
+
+  // Auto-scroll logic
+  $effect(() => {
+    // Depend on filteredLogs length to trigger scroll
+    if (filteredLogs.length && autoScroll && consoleElement) {
+        // Use tick to wait for DOM update
+        tick().then(() => {
             consoleElement.scrollTop = consoleElement.scrollHeight;
-        }
-    }, 0);
+        });
+    }
+  });
+
+  function handleScroll() {
+    if (!consoleElement) return;
+    const { scrollTop, scrollHeight, clientHeight } = consoleElement;
+    // If user scrolls up (more than 50px from bottom), disable auto-scroll
+    if (scrollHeight - scrollTop - clientHeight > 50) {
+      autoScroll = false;
+    } else {
+      autoScroll = true;
+    }
+  }
+
+  // Export only currently filtered logs
+  async function exportLogs() {
+    try {
+      const content = logsState.exportLogs(filteredLogs);
+      const path = await save({
+        filters: [{ name: "Log File", extensions: ["txt", "log"] }],
+        defaultPath: `dropout-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
+      });
+      
+      if (path) {
+        await writeTextFile(path, content);
+        logsState.addLog("info", "Console", `Exported ${filteredLogs.length} logs to ${path}`);
+      }
+    } catch (e) {
+      console.error("Export failed", e);
+      logsState.addLog("error", "Console", `Export failed: ${e}`);
+    }
   }
 
-  function clearLogs() {
-      logs = [];
+  // Upload only currently filtered logs
+  async function uploadLogs() {
+    try {
+        const content = logsState.exportLogs(filteredLogs);
+        logsState.addLog("info", "Console", `Uploading ${filteredLogs.length} logs...`);
+        
+        const response = await invoke<{ url: string }>("upload_to_pastebin", { content });
+        
+        logsState.addLog("info", "Console", `Logs uploaded successfully: ${response.url}`);
+        await open(response.url);
+    } catch (e) {
+        console.error("Upload failed", e);
+        logsState.addLog("error", "Console", `Upload failed: ${e}`);
+    }
   }
 
-  function exportLogs() {
-    const logText = logs.map(l => `[${l.timestamp}] [${l.type.toUpperCase()}] ${l.line}`).join('\n');
-    const blob = new Blob([logText], { type: 'text/plain' });
-    const url = URL.createObjectURL(blob);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = `dropout-logs-${new Date().toISOString().split('T')[0]}.txt`;
-    a.click();
-    URL.revokeObjectURL(url);
+  function highlightText(text: string, query: string) {
+    if (!query) return text;
+    // Escape regex special chars in query
+    const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+    const parts = text.split(new RegExp(`(${escaped})`, "gi"));
+    return parts.map(part => 
+        part.toLowerCase() === query.toLowerCase() 
+            ? `<span class="bg-yellow-500/30 text-yellow-200 font-bold">${part}</span>` 
+            : part
+    ).join("");
+  }
+
+  function getLevelColor(level: LogEntry["level"]) {
+    switch (level) {
+      case "info": return "text-blue-400";
+      case "warn": return "text-yellow-400";
+      case "error": 
+      case "fatal": return "text-red-400";
+      case "debug": return "text-purple-400";
+      default: return "text-zinc-400";
+    }
+  }
+
+  function getLevelLabel(level: LogEntry["level"]) {
+    switch (level) {
+      case "info": return "INFO";
+      case "warn": return "WARN";
+      case "error": return "ERR";
+      case "fatal": return "FATAL";
+      case "debug": return "DEBUG";
+    }
+  }
+
+  function getMessageColor(log: LogEntry) {
+    if (log.level === "error" || log.level === "fatal") return "text-red-300";
+    if (log.level === "warn") return "text-yellow-200";
+    if (log.level === "debug") return "text-purple-200/70";
+    if (log.source.startsWith("Game")) return "text-emerald-100/80";
+    return "";
   }
 </script>
 
-{#if visible}
-<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700  flex flex-col z-50 transition-transform duration-300 transform translate-y-0">
-  <div class="flex items-center justify-between px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
-    <div class="flex items-center gap-4">
-      <span class="text-xs font-bold text-zinc-400 uppercase tracking-widest">Logs</span>
-      <div class="flex gap-1 text-[10px]">
-        <span class="px-1.5 py-0.5 rounded bg-indigo-900/50 text-indigo-300">LAUNCHER</span>
-        <span class="px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-300">GAME</span>
-        <span class="px-1.5 py-0.5 rounded bg-red-900/50 text-red-300">ERROR</span>
-      </div>
+<div class="absolute inset-0 flex flex-col bg-[#1e1e1e] text-zinc-300 font-mono text-xs overflow-hidden">
+  <!-- Toolbar -->
+  <div class="flex flex-wrap items-center justify-between p-2 bg-[#252526] border-b border-[#3e3e42] gap-2">
+    <div class="flex items-center gap-3">
+        <h3 class="font-bold text-zinc-100 uppercase tracking-wider px-2">Console</h3>
+        
+        <!-- Source Dropdown -->
+        <select 
+            bind:value={selectedSource}
+            class="bg-zinc-900 border border-zinc-700 rounded-md px-3 py-1.5 pr-8 text-zinc-300 text-xs focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500/30 cursor-pointer hover:border-zinc-600 transition-colors"
+        >
+            <option value="all">All Sources</option>
+            {#each sortedSources as source}
+                <option value={source}>{source}</option>
+            {/each}
+        </select>
+
+        <!-- Level Filters -->
+        <div class="flex items-center bg-[#1e1e1e] rounded border border-[#3e3e42] overflow-hidden">
+            <button 
+                class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showInfo ? 'text-blue-400' : 'text-zinc-600'}" 
+                onclick={() => showInfo = !showInfo} 
+                title="Toggle Info"
+            >Info</button>
+            <div class="w-px h-3 bg-[#3e3e42]"></div>
+            <button 
+                class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showWarn ? 'text-yellow-400' : 'text-zinc-600'}" 
+                onclick={() => showWarn = !showWarn}
+                title="Toggle Warnings"
+            >Warn</button>
+            <div class="w-px h-3 bg-[#3e3e42]"></div>
+            <button 
+                class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showError ? 'text-red-400' : 'text-zinc-600'}" 
+                onclick={() => showError = !showError}
+                title="Toggle Errors"
+            >Error</button>
+            <div class="w-px h-3 bg-[#3e3e42]"></div>
+            <button 
+                class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showDebug ? 'text-purple-400' : 'text-zinc-600'}" 
+                onclick={() => showDebug = !showDebug}
+                title="Toggle Debug"
+            >Debug</button>
+        </div>
+
+        <!-- Search -->
+        <div class="relative group">
+            <input 
+                type="text" 
+                bind:value={searchQuery}
+                placeholder="Find..." 
+                class="bg-[#1e1e1e] border border-[#3e3e42] rounded pl-8 pr-2 py-1 focus:border-indigo-500 focus:outline-none w-40 text-zinc-300 placeholder:text-zinc-600 transition-all focus:w-64"
+            />
+            <svg class="w-3.5 h-3.5 text-zinc-500 absolute left-2.5 top-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
+            {#if searchQuery}
+                <button class="absolute right-2 top-1.5 text-zinc-500 hover:text-white" onclick={() => searchQuery = ""}>✕</button>
+            {/if}
+        </div>
     </div>
-    <div class="flex gap-2">
-        <button on:click={exportLogs} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Export</button>
-        <button on:click={clearLogs} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Clear</button>
-        <button on:click={() => visible = false} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Close</button>
+
+    <!-- Actions -->
+    <div class="flex items-center gap-2">
+        <!-- Log count indicator -->
+        <span class="text-zinc-500 text-[10px] px-2">{filteredLogs.length} / {logsState.logs.length}</span>
+        
+        <button 
+            onclick={() => logsState.clear()}
+            class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+            title="Clear Logs"
+        >
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
+        </button>
+        <button 
+            onclick={exportLogs}
+            class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+            title="Export Filtered Logs"
+        >
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
+        </button>
+        <button 
+            onclick={uploadLogs}
+            class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+            title="Upload Filtered Logs"
+        >
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
+        </button>
+        <div class="w-px h-4 bg-[#3e3e42] mx-1"></div>
+        <button 
+            onclick={() => uiState.toggleConsole()}
+            class="p-1.5 hover:bg-red-500/20 hover:text-red-400 rounded text-zinc-400 transition-colors"
+            title="Close"
+        >
+            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
+        </button>
     </div>
   </div>
-  <div bind:this={consoleElement} class="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
-    {#each logs as log}
-      <div class="flex whitespace-pre-wrap break-all {log.type === 'stderr' ? 'text-red-400' : log.type === 'launcher' ? 'text-indigo-300' : 'text-zinc-300'}">
-        <span class="text-zinc-600 mr-2 shrink-0">{log.timestamp}</span>
-        <span class="shrink-0 mr-2 {log.type === 'stderr' ? 'text-red-500' : log.type === 'launcher' ? 'text-indigo-500' : 'text-zinc-500'}">[{log.type === 'launcher' ? 'LAUNCHER' : log.type === 'stderr' ? 'ERROR' : 'GAME'}]</span>
-        <span class="break-all">{log.line}</span>
-      </div>
+
+  <!-- Log Area -->
+  <div 
+    bind:this={consoleElement}
+    onscroll={handleScroll}
+    class="flex-1 overflow-y-auto overflow-x-hidden p-2 select-text custom-scrollbar"
+  >
+    {#each filteredLogs as log (log.id)}
+        <div class="flex gap-2 leading-relaxed hover:bg-[#2a2d2e] px-1 rounded-sm group">
+            <!-- Timestamp -->
+            <span class="text-zinc-500 shrink-0 select-none w-20 text-right opacity-50 group-hover:opacity-100">{log.timestamp.split('.')[0]}</span>
+            
+            <!-- Source & Level -->
+            <div class="flex shrink-0 min-w-[140px] gap-1 justify-end font-bold select-none truncate">
+                <span class="text-zinc-500 truncate max-w-[90px]" title={log.source}>{log.source}</span>
+                <span class={getLevelColor(log.level)}>{getLevelLabel(log.level)}</span>
+            </div>
+
+            <!-- Message -->
+            <div class="flex-1 break-all whitespace-pre-wrap text-zinc-300 min-w-0 {getMessageColor(log)}">
+                {@html highlightText(log.message, searchQuery)}
+            </div>
+        </div>
     {/each}
-    {#if logs.length === 0}
-        <div class="text-zinc-600 italic">Waiting for output... Click "Show Logs" and start a game to see logs here.</div>
+    
+    {#if filteredLogs.length === 0}
+        <div class="text-center text-zinc-600 mt-10 italic select-none">
+            {#if logsState.logs.length === 0}
+                Waiting for logs...
+            {:else}
+                No logs match current filters.
+            {/if}
+        </div>
     {/if}
   </div>
+  
+  <!-- Auto-scroll status -->
+  {#if !autoScroll}
+    <button 
+        onclick={() => { autoScroll = true; consoleElement.scrollTop = consoleElement.scrollHeight; }}
+        class="absolute bottom-4 right-6 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded shadow-lg text-xs font-bold transition-all animate-bounce"
+    >
+        Resume Auto-scroll ⬇
+    </button>
+  {/if}
 </div>
-{/if}
+
+<style>
+    /* Custom Scrollbar for the log area */
+    .custom-scrollbar::-webkit-scrollbar {
+        width: 10px;
+        background-color: #1e1e1e;
+    }
+    .custom-scrollbar::-webkit-scrollbar-thumb {
+        background-color: #424242;
+        border: 2px solid #1e1e1e; /* padding around thumb */
+        border-radius: 0;
+    }
+    .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+        background-color: #4f4f4f;
+    }
+</style>
-- 
cgit v1.2.3-70-g09d2


From 31077dbd39a25eecd24a1dca0f8c9d1879265277 Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Thu, 15 Jan 2026 17:36:36 +0800
Subject: feat: Implement custom dropdown components for version selection in
 BottomBar and ModLoaderSelector, enhancing user interaction and UI
 consistency

---
 ui/src/app.css                             |  97 ++++++++++++++-----
 ui/src/components/BottomBar.svelte         |  97 ++++++++++++++++---
 ui/src/components/CustomSelect.svelte      | 136 ++++++++++++++++++++++++++
 ui/src/components/ModLoaderSelector.svelte | 150 ++++++++++++++++++++++++-----
 ui/src/components/SettingsView.svelte      |  33 ++++---
 ui/src/lib/GameConsole.svelte              |  19 ++--
 6 files changed, 443 insertions(+), 89 deletions(-)
 create mode 100644 ui/src/components/CustomSelect.svelte

(limited to 'ui/src/lib/GameConsole.svelte')

diff --git a/ui/src/app.css b/ui/src/app.css
index 5d0404b..82aa72f 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -14,43 +14,48 @@ select {
   padding-right: 2rem;
 }
 
-/* Custom scrollbar for dropdowns and lists */
-select,
-.custom-select {
-  scrollbar-width: thin;
-  scrollbar-color: #3f3f46 #18181b;
-}
-
-/* Webkit scrollbar for select (when expanded, browser-dependent) */
-select::-webkit-scrollbar {
-  width: 8px;
+/* Option styling - works in WebView/Chromium */
+select option {
+  background-color: #18181b;
+  color: #e4e4e7;
+  padding: 12px 16px;
+  font-size: 13px;
+  border: none;
 }
 
-select::-webkit-scrollbar-track {
-  background: #18181b;
+select option:hover,
+select option:focus {
+  background-color: #3730a3 !important;
+  color: white !important;
 }
 
-select::-webkit-scrollbar-thumb {
-  background-color: #3f3f46;
-  border-radius: 4px;
+select option:checked {
+  background: linear-gradient(0deg, #4f46e5 0%, #4f46e5 100%);
+  color: white;
+  font-weight: 500;
 }
 
-select::-webkit-scrollbar-thumb:hover {
-  background-color: #52525b;
+select option:disabled {
+  color: #52525b;
+  background-color: #18181b;
 }
 
-/* Option styling (limited browser support but good for Tauri/WebView) */
-select option {
+/* Optgroup styling */
+select optgroup {
   background-color: #18181b;
-  color: #e4e4e7;
-  padding: 8px 12px;
+  color: #a1a1aa;
+  font-weight: 600;
+  font-size: 11px;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  padding: 8px 12px 4px;
 }
 
-select option:hover,
-select option:focus,
-select option:checked {
-  background: linear-gradient(#3730a3, #3730a3);
-  color: white;
+/* Select focus state */
+select:focus {
+  outline: none;
+  border-color: #6366f1;
+  box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
 }
 
 /* ==================== Custom Scrollbar (Global) ==================== */
@@ -118,3 +123,43 @@ input[type="number"]::-webkit-inner-spin-button {
 input[type="number"] {
   -moz-appearance: textfield;
 }
+
+/* ==================== Checkbox Styling ==================== */
+
+input[type="checkbox"] {
+  appearance: none;
+  width: 16px;
+  height: 16px;
+  border: 1px solid #3f3f46;
+  border-radius: 4px;
+  background-color: #18181b;
+  cursor: pointer;
+  position: relative;
+  transition: all 0.15s ease;
+}
+
+input[type="checkbox"]:hover {
+  border-color: #52525b;
+}
+
+input[type="checkbox"]:checked {
+  background-color: #4f46e5;
+  border-color: #4f46e5;
+}
+
+input[type="checkbox"]:checked::after {
+  content: '';
+  position: absolute;
+  left: 5px;
+  top: 2px;
+  width: 4px;
+  height: 8px;
+  border: solid white;
+  border-width: 0 2px 2px 0;
+  transform: rotate(45deg);
+}
+
+input[type="checkbox"]:focus {
+  outline: none;
+  box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
+}
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index 198d4e6..abb0b23 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -2,7 +2,39 @@
   import { authState } from "../stores/auth.svelte";
   import { gameState } from "../stores/game.svelte";
   import { uiState } from "../stores/ui.svelte";
-  import { Terminal, ChevronDown, Play, User } from 'lucide-svelte';
+  import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte';
+
+  let isVersionDropdownOpen = $state(false);
+  let dropdownRef: HTMLDivElement;
+
+  let versionOptions = $derived(
+    gameState.versions.length === 0 
+      ? [{ id: "loading", type: "loading", label: "Loading..." }]
+      : gameState.versions.map(v => ({
+          ...v,
+          label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
+        }))
+  );
+
+  function selectVersion(id: string) {
+    if (id !== "loading") {
+      gameState.selectedVersion = id;
+      isVersionDropdownOpen = false;
+    }
+  }
+
+  function handleClickOutside(e: MouseEvent) {
+    if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
+      isVersionDropdownOpen = false;
+    }
+  }
+
+  $effect(() => {
+    if (isVersionDropdownOpen) {
+      document.addEventListener('click', handleClickOutside);
+      return () => document.removeEventListener('click', handleClickOutside);
+    }
+  });
 </script>
 
 <div
@@ -60,23 +92,56 @@
   <!-- Action Area -->
   <div class="flex items-center gap-4">
     <div class="flex flex-col items-end mr-2">
-      <div class="relative group">
-        <select
-          id="version-select"
-          bind:value={gameState.selectedVersion}
-          class="appearance-none dark:bg-zinc-900 bg-zinc-50 dark:text-white text-gray-900 border dark:border-white/10 border-black/10 rounded-sm pl-4 pr-10 py-2 dark:hover:border-white/30 hover:border-black/30 transition-all cursor-pointer outline-none focus:ring-1 focus:ring-zinc-500 w-56 text-sm font-mono"
+      <!-- Custom Version Dropdown -->
+      <div class="relative" bind:this={dropdownRef}>
+        <button
+          type="button"
+          onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
+          class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
+                 dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md 
+                 text-sm font-mono dark:text-white text-gray-900
+                 dark:hover:border-zinc-600 hover:border-zinc-400 
+                 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+                 transition-colors cursor-pointer outline-none"
         >
-          {#if gameState.versions.length === 0}
-            <option>Loading...</option>
-          {:else}
-            {#each gameState.versions as version}
-              <option value={version.id}>{version.id} {version.type !== 'release' ? `(${version.type})` : ''}</option>
+          <span class="truncate">
+            {#if gameState.versions.length === 0}
+              Loading...
+            {:else}
+              {gameState.selectedVersion || "Select version"}
+            {/if}
+          </span>
+          <ChevronDown 
+            size={14} 
+            class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" 
+          />
+        </button>
+
+        {#if isVersionDropdownOpen}
+          <div 
+            class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl
+                   max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1"
+          >
+            {#each versionOptions as version}
+              <button
+                type="button"
+                onclick={() => selectVersion(version.id)}
+                disabled={version.id === "loading"}
+                class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left
+                       transition-colors outline-none
+                       {version.id === gameState.selectedVersion 
+                         ? 'bg-indigo-600 text-white' 
+                         : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'}
+                       {version.id === 'loading' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
+              >
+                <span class="truncate">{version.label}</span>
+                {#if version.id === gameState.selectedVersion}
+                  <Check size={14} class="shrink-0 ml-2" />
+                {/if}
+              </button>
             {/each}
-          {/if}
-        </select>
-        <div class="absolute right-3 top-1/2 -translate-y-1/2 dark:text-white/20 text-black/20 pointer-events-none">
-            <ChevronDown size={14} />
-        </div>
+          </div>
+        {/if}
       </div>
     </div>
 
diff --git a/ui/src/components/CustomSelect.svelte b/ui/src/components/CustomSelect.svelte
new file mode 100644
index 0000000..2e89c75
--- /dev/null
+++ b/ui/src/components/CustomSelect.svelte
@@ -0,0 +1,136 @@
+<script lang="ts">
+  import { ChevronDown, Check } from 'lucide-svelte';
+
+  interface Option {
+    value: string;
+    label: string;
+    disabled?: boolean;
+  }
+
+  interface Props {
+    options: Option[];
+    value: string;
+    placeholder?: string;
+    disabled?: boolean;
+    class?: string;
+    onchange?: (value: string) => void;
+  }
+
+  let { 
+    options, 
+    value = $bindable(), 
+    placeholder = "Select...", 
+    disabled = false,
+    class: className = "",
+    onchange
+  }: Props = $props();
+
+  let isOpen = $state(false);
+  let containerRef: HTMLDivElement;
+
+  let selectedOption = $derived(options.find(o => o.value === value));
+
+  function toggle() {
+    if (!disabled) {
+      isOpen = !isOpen;
+    }
+  }
+
+  function select(option: Option) {
+    if (option.disabled) return;
+    value = option.value;
+    isOpen = false;
+    onchange?.(option.value);
+  }
+
+  function handleKeydown(e: KeyboardEvent) {
+    if (disabled) return;
+    
+    if (e.key === 'Enter' || e.key === ' ') {
+      e.preventDefault();
+      toggle();
+    } else if (e.key === 'Escape') {
+      isOpen = false;
+    } else if (e.key === 'ArrowDown' && isOpen) {
+      e.preventDefault();
+      const currentIndex = options.findIndex(o => o.value === value);
+      const nextIndex = Math.min(currentIndex + 1, options.length - 1);
+      if (!options[nextIndex].disabled) {
+        value = options[nextIndex].value;
+      }
+    } else if (e.key === 'ArrowUp' && isOpen) {
+      e.preventDefault();
+      const currentIndex = options.findIndex(o => o.value === value);
+      const prevIndex = Math.max(currentIndex - 1, 0);
+      if (!options[prevIndex].disabled) {
+        value = options[prevIndex].value;
+      }
+    }
+  }
+
+  function handleClickOutside(e: MouseEvent) {
+    if (containerRef && !containerRef.contains(e.target as Node)) {
+      isOpen = false;
+    }
+  }
+
+  $effect(() => {
+    if (isOpen) {
+      document.addEventListener('click', handleClickOutside);
+      return () => document.removeEventListener('click', handleClickOutside);
+    }
+  });
+</script>
+
+<div 
+  bind:this={containerRef}
+  class="relative {className}"
+>
+  <!-- Trigger Button -->
+  <button
+    type="button"
+    onclick={toggle}
+    onkeydown={handleKeydown}
+    {disabled}
+    class="w-full flex items-center justify-between gap-2 px-3 py-2 pr-8 text-left
+           bg-zinc-900 border border-zinc-700 rounded-md text-sm text-zinc-200
+           hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+           transition-colors cursor-pointer outline-none
+           disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700"
+  >
+    <span class="truncate {!selectedOption ? 'text-zinc-500' : ''}">
+      {selectedOption?.label || placeholder}
+    </span>
+    <ChevronDown 
+      size={14} 
+      class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}" 
+    />
+  </button>
+
+  <!-- Dropdown Menu -->
+  {#if isOpen}
+    <div 
+      class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl
+             max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+    >
+      {#each options as option}
+        <button
+          type="button"
+          onclick={() => select(option)}
+          disabled={option.disabled}
+          class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+                 transition-colors outline-none
+                 {option.value === value 
+                   ? 'bg-indigo-600 text-white' 
+                   : 'text-zinc-300 hover:bg-zinc-800'}
+                 {option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}"
+        >
+          <span class="truncate">{option.label}</span>
+          {#if option.value === value}
+            <Check size={14} class="shrink-0 ml-2" />
+          {/if}
+        </button>
+      {/each}
+    </div>
+  {/if}
+</div>
diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte
index d0c1b59..cb949c5 100644
--- a/ui/src/components/ModLoaderSelector.svelte
+++ b/ui/src/components/ModLoaderSelector.svelte
@@ -6,7 +6,7 @@
     ForgeVersion,
     ModLoaderType,
   } from "../types";
-  import { Loader2, Download, AlertCircle, Check } from 'lucide-svelte';
+  import { Loader2, Download, AlertCircle, Check, ChevronDown } from 'lucide-svelte';
 
   interface Props {
     selectedGameVersion: string;
@@ -23,10 +23,15 @@
   // Fabric state
   let fabricLoaders = $state<FabricLoaderVersion[]>([]);
   let selectedFabricLoader = $state("");
+  let isFabricDropdownOpen = $state(false);
 
   // Forge state
   let forgeVersions = $state<ForgeVersion[]>([]);
   let selectedForgeVersion = $state("");
+  let isForgeDropdownOpen = $state(false);
+
+  let fabricDropdownRef = $state<HTMLDivElement | null>(null);
+  let forgeDropdownRef = $state<HTMLDivElement | null>(null);
 
   // Load mod loader versions when game version changes
   $effect(() => {
@@ -111,6 +116,44 @@
       loadModLoaderVersions();
     }
   }
+
+  function handleFabricClickOutside(e: MouseEvent) {
+    if (fabricDropdownRef && !fabricDropdownRef.contains(e.target as Node)) {
+      isFabricDropdownOpen = false;
+    }
+  }
+
+  function handleForgeClickOutside(e: MouseEvent) {
+    if (forgeDropdownRef && !forgeDropdownRef.contains(e.target as Node)) {
+      isForgeDropdownOpen = false;
+    }
+  }
+
+  $effect(() => {
+    if (isFabricDropdownOpen) {
+      document.addEventListener('click', handleFabricClickOutside);
+      return () => document.removeEventListener('click', handleFabricClickOutside);
+    }
+  });
+
+  $effect(() => {
+    if (isForgeDropdownOpen) {
+      document.addEventListener('click', handleForgeClickOutside);
+      return () => document.removeEventListener('click', handleForgeClickOutside);
+    }
+  });
+
+  let selectedFabricLabel = $derived(
+    fabricLoaders.find(l => l.version === selectedFabricLoader)
+      ? `${selectedFabricLoader}${fabricLoaders.find(l => l.version === selectedFabricLoader)?.stable ? ' (stable)' : ''}`
+      : selectedFabricLoader || 'Select version'
+  );
+
+  let selectedForgeLabel = $derived(
+    forgeVersions.find(v => v.version === selectedForgeVersion)
+      ? `${selectedForgeVersion}${forgeVersions.find(v => v.version === selectedForgeVersion)?.recommended ? ' (Recommended)' : ''}`
+      : selectedForgeVersion || 'Select version'
+  );
 </script>
 
 <div class="space-y-4">
@@ -163,18 +206,48 @@
             <label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
             >Loader Version</label
             >
-            <div class="relative">
-                <select
-                id="fabric-loader-select"
-                class="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md px-4 py-2.5 pr-10 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 text-gray-900 dark:text-white transition-colors cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600"
-                bind:value={selectedFabricLoader}
+            <!-- Custom Fabric Dropdown -->
+            <div class="relative" bind:this={fabricDropdownRef}>
+                <button
+                    type="button"
+                    onclick={() => isFabricDropdownOpen = !isFabricDropdownOpen}
+                    class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
+                           bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md 
+                           text-sm text-gray-900 dark:text-white
+                           hover:border-zinc-400 dark:hover:border-zinc-600 
+                           focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+                           transition-colors cursor-pointer outline-none"
                 >
-                {#each fabricLoaders as loader}
-                    <option value={loader.version}>
-                    {loader.version} {loader.stable ? "(stable)" : ""}
-                    </option>
-                {/each}
-                </select>
+                    <span class="truncate">{selectedFabricLabel}</span>
+                    <ChevronDown 
+                        size={14} 
+                        class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isFabricDropdownOpen ? 'rotate-180' : ''}" 
+                    />
+                </button>
+
+                {#if isFabricDropdownOpen}
+                    <div 
+                        class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
+                               max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+                    >
+                        {#each fabricLoaders as loader}
+                            <button
+                                type="button"
+                                onclick={() => { selectedFabricLoader = loader.version; isFabricDropdownOpen = false; }}
+                                class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+                                       transition-colors outline-none cursor-pointer
+                                       {loader.version === selectedFabricLoader 
+                                         ? 'bg-indigo-600 text-white' 
+                                         : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
+                            >
+                                <span class="truncate">{loader.version} {loader.stable ? "(stable)" : ""}</span>
+                                {#if loader.version === selectedFabricLoader}
+                                    <Check size={14} class="shrink-0 ml-2" />
+                                {/if}
+                            </button>
+                        {/each}
+                    </div>
+                {/if}
             </div>
         </div>
         
@@ -199,19 +272,48 @@
             <label for="forge-version-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
                 >Forge Version</label
             >
-            <div class="relative">
-                <select
-                    id="forge-version-select"
-                    class="w-full bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md px-4 py-2.5 pr-10 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 text-gray-900 dark:text-white transition-colors cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600"
-                    bind:value={selectedForgeVersion}
+            <!-- Custom Forge Dropdown -->
+            <div class="relative" bind:this={forgeDropdownRef}>
+                <button
+                    type="button"
+                    onclick={() => isForgeDropdownOpen = !isForgeDropdownOpen}
+                    class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
+                           bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md 
+                           text-sm text-gray-900 dark:text-white
+                           hover:border-zinc-400 dark:hover:border-zinc-600 
+                           focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+                           transition-colors cursor-pointer outline-none"
                 >
-                    {#each forgeVersions as version}
-                    <option value={version.version}>
-                        {version.version}
-                        {version.recommended ? " (Recommended)" : ""}
-                    </option>
-                    {/each}
-                </select>
+                    <span class="truncate">{selectedForgeLabel}</span>
+                    <ChevronDown 
+                        size={14} 
+                        class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isForgeDropdownOpen ? 'rotate-180' : ''}" 
+                    />
+                </button>
+
+                {#if isForgeDropdownOpen}
+                    <div 
+                        class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
+                               max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+                    >
+                        {#each forgeVersions as version}
+                            <button
+                                type="button"
+                                onclick={() => { selectedForgeVersion = version.version; isForgeDropdownOpen = false; }}
+                                class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
+                                       transition-colors outline-none cursor-pointer
+                                       {version.version === selectedForgeVersion 
+                                         ? 'bg-indigo-600 text-white' 
+                                         : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
+                            >
+                                <span class="truncate">{version.version} {version.recommended ? "(Recommended)" : ""}</span>
+                                {#if version.version === selectedForgeVersion}
+                                    <Check size={14} class="shrink-0 ml-2" />
+                                {/if}
+                            </button>
+                        {/each}
+                    </div>
+                {/if}
             </div>
             </div>
             
diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 732f857..76d441b 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -1,11 +1,22 @@
 <script lang="ts">
   import { open } from "@tauri-apps/plugin-dialog";
   import { settingsState } from "../stores/settings.svelte";
+  import CustomSelect from "./CustomSelect.svelte";
 
   // Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach
   // or use the imported one if passing raw path.
   import { convertFileSrc } from "@tauri-apps/api/core";
 
+  const effectOptions = [
+    { value: "saturn", label: "Saturn" },
+    { value: "constellation", label: "Network (Constellation)" }
+  ];
+
+  const logServiceOptions = [
+    { value: "paste.rs", label: "paste.rs (Free, No Account)" },
+    { value: "pastebin.com", label: "pastebin.com (Requires API Key)" }
+  ];
+
   async function selectBackground() {
     try {
       const selected = await open({
@@ -116,15 +127,12 @@
                        <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="theme-effect-label">Theme Effect</h4>
                        <p class="text-xs dark:text-white/40 text-black/50 mt-1">Select the active visual theme.</p>
                     </div>
-                    <select
-                        aria-labelledby="theme-effect-label"
+                    <CustomSelect
+                        options={effectOptions}
                         bind:value={settingsState.settings.active_effect}
                         onchange={() => settingsState.saveSettings()}
-                        class="dark:bg-zinc-900 bg-white dark:text-white text-black text-xs px-3 py-2 pr-8 rounded-lg border dark:border-zinc-700 border-gray-300 outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 cursor-pointer hover:border-zinc-600 transition-colors"
-                    >
-                        <option value="saturn">Saturn (Saturn)</option>
-                        <option value="constellation">Network (Constellation)</option>
-                    </select>
+                        class="w-52"
+                    />
                  </div>
              {/if}
 
@@ -308,14 +316,11 @@
         <div class="space-y-4">
             <div>
                 <label for="log-service" class="block text-sm font-medium text-white/70 mb-2">Log Upload Service</label>
-                <select
-                    id="log-service"
+                <CustomSelect
+                    options={logServiceOptions}
                     bind:value={settingsState.settings.log_upload_service}
-                    class="dark:bg-zinc-900 bg-white dark:text-white text-black w-full px-4 py-3 pr-10 rounded-xl border dark:border-zinc-700 border-gray-300 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 outline-none cursor-pointer hover:border-zinc-600 transition-colors"
-                >
-                    <option value="paste.rs">paste.rs (Free, No Account)</option>
-                    <option value="pastebin.com">pastebin.com (Requires API Key)</option>
-                </select>
+                    class="w-full"
+                />
             </div>
 
             {#if settingsState.settings.log_upload_service === 'pastebin.com'}
diff --git a/ui/src/lib/GameConsole.svelte b/ui/src/lib/GameConsole.svelte
index 1b1ab53..bc5edbc 100644
--- a/ui/src/lib/GameConsole.svelte
+++ b/ui/src/lib/GameConsole.svelte
@@ -6,6 +6,8 @@
   import { invoke } from "@tauri-apps/api/core";
   import { open } from "@tauri-apps/plugin-shell";
   import { onMount, tick } from "svelte";
+  import CustomSelect from "../components/CustomSelect.svelte";
+  import { ChevronDown, Check } from 'lucide-svelte';
 
   let consoleElement: HTMLDivElement;
   let autoScroll = $state(true);
@@ -21,7 +23,10 @@
   let selectedSource = $state("all");
 
   // Get sorted sources for dropdown
-  let sortedSources = $derived([...logsState.sources].sort());
+  let sourceOptions = $derived([
+    { value: "all", label: "All Sources" },
+    ...[...logsState.sources].sort().map(s => ({ value: s, label: s }))
+  ]);
 
   // Derived filtered logs
   let filteredLogs = $derived(logsState.logs.filter((log) => {
@@ -151,15 +156,11 @@
         <h3 class="font-bold text-zinc-100 uppercase tracking-wider px-2">Console</h3>
         
         <!-- Source Dropdown -->
-        <select 
+        <CustomSelect
+            options={sourceOptions}
             bind:value={selectedSource}
-            class="bg-zinc-900 border border-zinc-700 rounded-md px-3 py-1.5 pr-8 text-zinc-300 text-xs focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500/30 cursor-pointer hover:border-zinc-600 transition-colors"
-        >
-            <option value="all">All Sources</option>
-            {#each sortedSources as source}
-                <option value={source}>{source}</option>
-            {/each}
-        </select>
+            class="w-36"
+        />
 
         <!-- Level Filters -->
         <div class="flex items-center bg-[#1e1e1e] rounded border border-[#3e3e42] overflow-hidden">
-- 
cgit v1.2.3-70-g09d2

