1<script lang="ts">
2 import { onMount } from "svelte";
3 import { ModeWatcher } from "mode-watcher";
4 import ConfigShell from "./config/ConfigShell.svelte";
5 import ShortcutsTable from "./config/ShortcutsTable.svelte";
6 import KeybindingsTab from "./config/KeybindingsTab.svelte";
7 import * as AlertDialog from "$lib/components/ui/alert-dialog";
8 import { Button } from "$lib/components/ui/button";
9 import { ColorPicker } from "$lib/components/ui/color-picker";
10 import { Input } from "$lib/components/ui/input";
11 import { Label } from "$lib/components/ui/label";
12 import { Spinner } from "$lib/components/ui/spinner";
13 import * as Card from "$lib/components/ui/card";
14 import * as Tabs from "$lib/components/ui/tabs";
15 import { FlaskConical, RefreshCw } from "@lucide/svelte";
16
17 function getErrorMessage(e: unknown): string {
18 if (e instanceof Error) return e.message;
19 if (typeof e === "string") return e;
20 return "Unknown error";
21 }
22
23 type ColorPalette = {
24 background: string;
25 surface: string;
26 surface_variant: string;
27 text: string;
28 muted: string;
29 accent: string;
30 border: string;
31 };
32
33 type SearchShortcut = {
34 url: string;
35 description: string;
36 };
37
38 type ResolvedPerformance = {
39 skia_cpu_threads: number;
40 skia_gpu_threads: number;
41 web_process_memory_mb: number;
42 network_process_memory_mb: number;
43 webview_pool_prewarm: number;
44 conservative_threshold: number;
45 strict_threshold: number;
46 };
47
48 type HardwareInfo = {
49 cpu_cores: number;
50 cpu_threads: number;
51 total_ram_mb: number;
52 gpu_vendor: string;
53 gpu_name: string;
54 vram_mb: number;
55 };
56
57 type CustomPerformance = {
58 skia_cpu_threads: number;
59 skia_gpu_threads: number;
60 web_process_memory_mb: number;
61 network_process_memory_mb: number;
62 webview_pool_prewarm: number;
63 };
64
65 type PerformanceConfig = {
66 profile: string;
67 custom: CustomPerformance;
68 resolved: ResolvedPerformance;
69 hardware: HardwareInfo;
70 };
71
72 type ConfigDTO = {
73 appearance: {
74 sans_font: string;
75 serif_font: string;
76 monospace_font: string;
77 default_font_size: number;
78 color_scheme: string;
79 light_palette: ColorPalette;
80 dark_palette: ColorPalette;
81 };
82 performance: PerformanceConfig;
83 default_ui_scale: number;
84 default_search_engine: string;
85 search_shortcuts: Record<string, SearchShortcut>;
86 };
87
88 const PERFORMANCE_PROFILES = [
89 { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." },
90 { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." },
91 { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." },
92 { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." },
93 ];
94
95 const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
96 { key: "background", label: "Background" },
97 { key: "surface", label: "Surface" },
98 { key: "surface_variant", label: "Surface Variant" },
99 { key: "text", label: "Text" },
100 { key: "muted", label: "Muted" },
101 { key: "accent", label: "Accent" },
102 { key: "border", label: "Border" },
103 ];
104
105 function isHexColor(value: string): boolean {
106 return /^#[0-9a-fA-F]{6}$/.test(value);
107 }
108
109 // UI state
110 let loading = $state(true);
111 let loadError = $state<string | null>(null);
112 let config = $state<ConfigDTO | null>(null);
113 let saving = $state(false);
114 let saveSuccess = $state(false);
115 let saveError = $state<string | null>(null);
116 let showRestartWarning = $state(false);
117 let themeEvents = $state(0);
118 let activeTab = $state("appearance");
119
120 // Load config from API
121 async function loadConfig() {
122 loading = true;
123 loadError = null;
124 try {
125 const response = await fetch("/api/config");
126 if (!response.ok) throw new Error("Failed to fetch config");
127 config = (await response.json()) as ConfigDTO;
128 } catch (e: unknown) {
129 loadError = getErrorMessage(e);
130 console.error("[config] load failed", e);
131 } finally {
132 loading = false;
133 }
134 }
135
136 // Reload config (used after save to get normalized values)
137 function reloadConfig() {
138 loadConfig();
139 }
140
141 let resetDialogOpen = $state(false);
142
143 async function doResetToDefaults() {
144 saveSuccess = false;
145 try {
146 const response = await fetch("/api/config/default");
147 if (!response.ok) throw new Error("Failed to fetch default config");
148 config = (await response.json()) as ConfigDTO;
149 } catch (e: unknown) {
150 console.error("[config] reset defaults failed", e);
151 }
152 resetDialogOpen = false;
153 }
154
155 function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
156 const bridge = window.webkit?.messageHandlers?.dumber;
157 if (bridge && typeof bridge.postMessage === "function") {
158 return bridge;
159 }
160 return null;
161 }
162
163 function getWebViewId(): number {
164 return window.__dumber_webview_id || 0;
165 }
166
167 async function saveConfig() {
168 saving = true;
169 saveSuccess = false;
170 saveError = null;
171
172 try {
173 const bridge = getWebKitBridge();
174 const webviewId = getWebViewId();
175
176 console.debug("[config] save click", {
177 hasBridge: Boolean(bridge),
178 webviewId,
179 hasConfig: Boolean(config),
180 });
181
182 if (!bridge) {
183 throw new Error("WebKit bridge not available (not running inside Dumber)");
184 }
185 if (!config) {
186 throw new Error("Config not loaded");
187 }
188
189 let timeout: number | null = window.setTimeout(() => {
190 if (saving) {
191 console.warn("[config] save timed out (no callback)");
192 saveError = "Save timed out (no response from native handler)";
193 saving = false;
194 }
195 }, 8000);
196
197 const clearSaveTimeout = () => {
198 if (timeout != null) {
199 window.clearTimeout(timeout);
200 timeout = null;
201 }
202 };
203
204 window.__dumber_config_saved = (resp?: unknown) => {
205 clearSaveTimeout();
206 console.debug("[config] save success", resp);
207 saveSuccess = true;
208 saving = false;
209
210 // Show restart warning if performance tab was active
211 if (activeTab === "performance") {
212 showRestartWarning = true;
213 }
214
215 // Refresh config from backend (in case watcher normalized values)
216 reloadConfig();
217
218 setTimeout(() => {
219 saveSuccess = false;
220 }, 3000);
221 };
222 window.__dumber_config_error = (msg: unknown) => {
223 clearSaveTimeout();
224 console.error("[config] save error", msg);
225 saveError = typeof msg === "string" ? msg : "Failed to save config";
226 saving = false;
227 };
228
229 const payload = $state.snapshot(config);
230
231 try {
232 bridge.postMessage({
233 type: "save_config",
234 webview_id: webviewId,
235 payload,
236 });
237 } catch (postErr) {
238 console.error("[config] postMessage threw", postErr);
239 throw postErr;
240 }
241 } catch (e: unknown) {
242 console.error("[config] save exception", e);
243 saveError = getErrorMessage(e);
244 saving = false;
245 }
246 }
247
248 function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
249 if (!config) return;
250 config.search_shortcuts = nextShortcuts;
251 }
252
253 onMount(() => {
254 console.debug("[config] mount", {
255 webviewId: window.__dumber_webview_id,
256 hasBridge: Boolean(window.webkit?.messageHandlers?.dumber),
257 });
258
259 const onThemeChanged = (e: Event) => {
260 themeEvents += 1;
261 console.debug("[config] theme changed", (e as CustomEvent).detail);
262 };
263 window.addEventListener("dumber:theme-changed", onThemeChanged);
264
265 // Load config on mount
266 loadConfig();
267
268 return () => {
269 window.removeEventListener("dumber:theme-changed", onThemeChanged);
270 };
271 });
272</script>
273
274<ModeWatcher />
275<ConfigShell>
276 <div class="flex w-full flex-col gap-6 px-6 py-8">
277 {#if loading}
278 <!-- Loading state with spinner -->
279 <div class="flex flex-col items-center justify-center gap-4 py-16">
280 <Spinner class="size-8" />
281 <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground">
282 Loading configuration…
283 </div>
284 </div>
285 {:else if loadError}
286 <!-- Error state -->
287 <div class="flex flex-col items-center justify-center gap-4 py-16">
288 <Card.Root class="max-w-md">
289 <Card.Header>
290 <Card.Title class="text-destructive">Failed to load config</Card.Title>
291 <Card.Description>{loadError}</Card.Description>
292 </Card.Header>
293 <Card.Content>
294 <Button variant="outline" onclick={reloadConfig}>Try again</Button>
295 </Card.Content>
296 </Card.Root>
297 </div>
298 {:else if config}
299 <Tabs.Root bind:value={activeTab} class="w-full">
300 <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
301 <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
302 Config Workspace
303 </div>
304 <Tabs.List class="bg-muted/60">
305 <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
306 <Tabs.Trigger value="search">Search</Tabs.Trigger>
307 <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
308 <Tabs.Trigger value="performance">Performance</Tabs.Trigger>
309 </Tabs.List>
310 </div>
311
312 <Tabs.Content value="appearance">
313 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
314 <Card.Header>
315 <Card.Title>Appearance</Card.Title>
316 <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
317 </Card.Header>
318 <Card.Content class="space-y-8">
319 <div class="grid gap-6 md:grid-cols-2">
320 <div class="space-y-2">
321 <Label for="sans_font">Sans-Serif Font</Label>
322 <Input id="sans_font" bind:value={config.appearance.sans_font} />
323 </div>
324 <div class="space-y-2">
325 <Label for="serif_font">Serif Font</Label>
326 <Input id="serif_font" bind:value={config.appearance.serif_font} />
327 </div>
328 <div class="space-y-2">
329 <Label for="monospace_font">Monospace Font</Label>
330 <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
331 </div>
332 <div class="space-y-2">
333 <Label for="font_size">Default Font Size</Label>
334 <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
335 </div>
336 <div class="space-y-2">
337 <Label for="color_scheme">Color Scheme</Label>
338 <select
339 id="color_scheme"
340 bind:value={config.appearance.color_scheme}
341 class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
342 >
343 <option value="default">Follow System</option>
344 <option value="prefer-dark">Always Dark</option>
345 <option value="prefer-light">Always Light</option>
346 </select>
347 </div>
348 <div class="space-y-2">
349 <Label for="ui_scale">UI Scale</Label>
350 <Input
351 id="ui_scale"
352 type="number"
353 step="0.1"
354 bind:value={config.default_ui_scale}
355 />
356 </div>
357 </div>
358
359 <div class="space-y-6">
360 <div class="space-y-3">
361 <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
362 Theme: Light
363 </div>
364 <div class="grid gap-4 md:grid-cols-2">
365 {#each PALETTE_FIELDS as field (field.key)}
366 <div class="space-y-2">
367 <Label for={`light_${field.key}`}>{field.label}</Label>
368 <div class="flex items-center gap-3">
369 <ColorPicker
370 id={`light_${field.key}`}
371 value={config.appearance.light_palette[field.key]}
372 onValueChange={(v) => {
373 config!.appearance.light_palette[field.key] = v;
374 }}
375 />
376 <Input bind:value={config.appearance.light_palette[field.key]} />
377 </div>
378 {#if !isHexColor(config.appearance.light_palette[field.key])}
379 <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
380 {/if}
381 </div>
382 {/each}
383 </div>
384 </div>
385
386 <div class="space-y-3">
387 <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
388 Theme: Dark
389 </div>
390 <div class="grid gap-4 md:grid-cols-2">
391 {#each PALETTE_FIELDS as field (field.key)}
392 <div class="space-y-2">
393 <Label for={`dark_${field.key}`}>{field.label}</Label>
394 <div class="flex items-center gap-3">
395 <ColorPicker
396 id={`dark_${field.key}`}
397 value={config.appearance.dark_palette[field.key]}
398 onValueChange={(v) => {
399 config!.appearance.dark_palette[field.key] = v;
400 }}
401 />
402 <Input bind:value={config.appearance.dark_palette[field.key]} />
403 </div>
404 {#if !isHexColor(config.appearance.dark_palette[field.key])}
405 <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
406 {/if}
407 </div>
408 {/each}
409 </div>
410 </div>
411 </div>
412 </Card.Content>
413 </Card.Root>
414 </Tabs.Content>
415
416 <Tabs.Content value="search">
417 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
418 <Card.Header>
419 <Card.Title>Search</Card.Title>
420 <Card.Description>Default engine and shortcuts.</Card.Description>
421 </Card.Header>
422 <Card.Content class="space-y-8">
423 <div class="space-y-2">
424 <Label for="search_engine">Default Search Engine (use %s for query)</Label>
425 <Input id="search_engine" bind:value={config.default_search_engine} />
426 </div>
427
428 <div class="space-y-3">
429 <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
430 Search Shortcuts
431 </div>
432 <p class="text-sm text-muted-foreground">Map prefixes like g to URL templates.</p>
433 <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
434 </div>
435 </Card.Content>
436 </Card.Root>
437 </Tabs.Content>
438
439 <Tabs.Content value="keybindings">
440 <KeybindingsTab />
441 </Tabs.Content>
442
443 <Tabs.Content value="performance">
444 <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
445 <Card.Header>
446 <Card.Title>Performance Profile</Card.Title>
447 <Card.Description>
448 WebKitGTK rendering and memory tuning presets.
449 </Card.Description>
450 </Card.Header>
451 <Card.Content class="space-y-6">
452 <!-- Detected Hardware Card -->
453 {#if config.performance?.hardware}
454 {@const hw = config.performance.hardware}
455 <div class="rounded-md border border-border bg-muted/30 px-4 py-3">
456 <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
457 Detected Hardware
458 </div>
459 <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
460 <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div>
461 <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div>
462 <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div>
463 <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div>
464 </div>
465 </div>
466 {/if}
467
468 <!-- Experimental feature warning -->
469 <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3">
470 <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" />
471 <div class="space-y-1">
472 <p class="text-sm font-medium text-info">Experimental Feature</p>
473 <p class="text-xs text-muted-foreground">
474 Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version.
475 </p>
476 </div>
477 </div>
478
479 <!-- Profile selector -->
480 {#if config.performance}
481 <div class="space-y-4">
482 {#each PERFORMANCE_PROFILES as profile (profile.value)}
483 <label
484 class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}"
485 >
486 <input
487 type="radio"
488 name="performance_profile"
489 value={profile.value}
490 checked={config.performance.profile === profile.value}
491 onchange={() => config!.performance.profile = profile.value}
492 class="mt-1 accent-primary"
493 />
494 <div class="space-y-1">
495 <div class="font-medium">{profile.label}</div>
496 <div class="text-sm text-muted-foreground">{profile.description}</div>
497 </div>
498 </label>
499 {/each}
500 </div>
501 {:else}
502 <div class="text-sm text-muted-foreground">
503 Performance settings not available. This may be due to a configuration error.
504 </div>
505 {/if}
506
507 <!-- Custom profile fields -->
508 {#if config.performance?.profile === "custom"}
509 <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4">
510 <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
511 Custom Settings
512 </div>
513 <div class="grid gap-4 md:grid-cols-2">
514 <div class="space-y-2">
515 <Label for="skia_cpu_threads">Skia CPU Threads</Label>
516 <Input
517 id="skia_cpu_threads"
518 type="number"
519 min="0"
520 max="8"
521 bind:value={config.performance.custom.skia_cpu_threads}
522 />
523 <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p>
524 </div>
525 <div class="space-y-2">
526 <Label for="skia_gpu_threads">Skia GPU Threads</Label>
527 <Input
528 id="skia_gpu_threads"
529 type="number"
530 min="-1"
531 bind:value={config.performance.custom.skia_gpu_threads}
532 />
533 <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p>
534 </div>
535 <div class="space-y-2">
536 <Label for="web_process_memory">Web Process Memory (MB)</Label>
537 <Input
538 id="web_process_memory"
539 type="number"
540 min="0"
541 step="256"
542 bind:value={config.performance.custom.web_process_memory_mb}
543 />
544 <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
545 </div>
546 <div class="space-y-2">
547 <Label for="network_process_memory">Network Process Memory (MB)</Label>
548 <Input
549 id="network_process_memory"
550 type="number"
551 min="0"
552 step="128"
553 bind:value={config.performance.custom.network_process_memory_mb}
554 />
555 <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
556 </div>
557 <div class="space-y-2">
558 <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label>
559 <Input
560 id="webview_pool_prewarm"
561 type="number"
562 min="0"
563 max="20"
564 bind:value={config.performance.custom.webview_pool_prewarm}
565 />
566 <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p>
567 </div>
568 </div>
569 </div>
570 {/if}
571
572 <!-- Resolved values display -->
573 {#if config.performance?.resolved}
574 <div class="space-y-3">
575 <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
576 Resolved Settings (applied on restart)
577 </div>
578 <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm">
579 <div class="space-y-1">
580 <div class="flex justify-between">
581 <span class="font-medium">Skia CPU Threads</span>
582 <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span>
583 </div>
584 <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p>
585 </div>
586 <div class="space-y-1">
587 <div class="flex justify-between">
588 <span class="font-medium">Skia GPU Threads</span>
589 <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span>
590 </div>
591 <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p>
592 </div>
593 <div class="space-y-1">
594 <div class="flex justify-between">
595 <span class="font-medium">Web Process Memory</span>
596 <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span>
597 </div>
598 <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p>
599 </div>
600 <div class="space-y-1">
601 <div class="flex justify-between">
602 <span class="font-medium">Network Process Memory</span>
603 <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span>
604 </div>
605 <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p>
606 </div>
607 <div class="space-y-1">
608 <div class="flex justify-between">
609 <span class="font-medium">WebView Pool Prewarm</span>
610 <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span>
611 </div>
612 <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p>
613 </div>
614 </div>
615 </div>
616 {/if}
617 </Card.Content>
618 </Card.Root>
619 </Tabs.Content>
620 </Tabs.Root>
621
622 <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
623 <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
624 {#if saveSuccess}
625 <span class="text-primary">Saved</span>
626 {:else}
627 <span>Ready</span>
628 {/if}
629 {#if themeEvents > 0}
630 <span>Theme x{themeEvents}</span>
631 {/if}
632 </div>
633 {#if activeTab !== "keybindings"}
634 <div class="flex items-center gap-3">
635 <AlertDialog.Root bind:open={resetDialogOpen}>
636 <AlertDialog.Trigger disabled={saving}>
637 {#snippet child({ props })}
638 <Button variant="outline" {...props} type="button">
639 Reset Defaults
640 </Button>
641 {/snippet}
642 </AlertDialog.Trigger>
643 <AlertDialog.Content>
644 <AlertDialog.Header>
645 <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
646 <AlertDialog.Description>
647 This will reset all settings on this page to their default values.
648 You will still need to click Save to persist the changes.
649 </AlertDialog.Description>
650 </AlertDialog.Header>
651 <AlertDialog.Footer>
652 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
653 <AlertDialog.Action onclick={doResetToDefaults}>
654 Reset Defaults
655 </AlertDialog.Action>
656 </AlertDialog.Footer>
657 </AlertDialog.Content>
658 </AlertDialog.Root>
659 <Button onclick={saveConfig} disabled={saving} type="button">
660 {saving ? "Saving…" : "Save"}
661 </Button>
662 </div>
663 {/if}
664 </div>
665
666 <!-- Save error display -->
667 {#if saveError}
668 <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
669 {saveError}
670 </div>
671 {/if}
672
673 <!-- Restart warning after saving performance settings -->
674 {#if showRestartWarning}
675 <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3">
676 <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" />
677 <div class="space-y-1">
678 <p class="text-sm font-medium text-warning">Restart Required</p>
679 <p class="text-xs text-muted-foreground">
680 Performance settings are applied at browser startup. Restart Dumber for changes to take effect.
681 </p>
682 </div>
683 </div>
684 {/if}
685 {/if}
686 </div>
687</ConfigShell>