1<script lang="ts">
2 import { onMount, onDestroy } from 'svelte';
3 import { homepageState } from '../state.svelte';
4 import { initializeHomepage } from '../messaging';
5 import { initializeKeyboard } from '../keyboard';
6 import CommandPalette from './CommandPalette.svelte';
7 import StatusBar from './StatusBar.svelte';
8 import KeyboardHints from './KeyboardHints.svelte';
9 import { History, Star, BarChart3, Search, Moon, Sun, Settings } from '@lucide/svelte';
10
11 // Props for slot content
12 interface Props {
13 children?: import('svelte').Snippet;
14 }
15 let { children }: Props = $props();
16
17 // Theme state
18 type ThemeMode = 'light' | 'dark';
19 let themeMode = $state<ThemeMode>('dark');
20 let themeObserver: MutationObserver | null = null;
21
22 // Keyboard cleanup
23 let cleanupKeyboard: (() => void) | null = null;
24
25 // Panel tabs config
26 const panelTabs = [
27 { id: 'history' as const, label: 'HIST', icon: History },
28 { id: 'favorites' as const, label: 'FAV', icon: Star },
29 { id: 'analytics' as const, label: 'STAT', icon: BarChart3 },
30 ];
31
32 // Theme management
33 const syncThemeState = () => {
34 themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
35 };
36
37 function navigateWithViewTransition(url: string) {
38 const doc = document as any;
39
40 if (typeof doc?.startViewTransition !== 'function') {
41 window.location.href = url;
42 return;
43 }
44
45 const transition = doc.startViewTransition(() => {
46 document.documentElement.dataset.vt = 'leaving';
47 });
48
49 transition.finished.finally(() => {
50 window.location.href = url;
51 });
52 }
53
54 const toggleTheme = () => {
55 const nextMode = themeMode === 'dark' ? 'light' : 'dark';
56 const manager = (window as any).__dumber_color_scheme_manager as
57 | { setUserPreference?: (theme: ThemeMode) => void }
58 | undefined;
59
60 if (manager?.setUserPreference) {
61 manager.setUserPreference(nextMode);
62 return;
63 }
64
65 if ((window as any).__dumber_setTheme) {
66 (window as any).__dumber_setTheme(nextMode);
67 localStorage.setItem('dumber.theme', nextMode);
68 return;
69 }
70
71 switch (nextMode) {
72 case 'light':
73 document.documentElement.classList.add('light');
74 document.documentElement.classList.remove('dark');
75 break;
76 case 'dark':
77 document.documentElement.classList.remove('light');
78 document.documentElement.classList.add('dark');
79 break;
80 }
81 localStorage.setItem('dumber.theme', nextMode);
82 themeMode = nextMode;
83 };
84
85 onMount(async () => {
86 // Initialize keyboard navigation
87 cleanupKeyboard = initializeKeyboard();
88
89 // Initialize data fetching
90 await initializeHomepage();
91
92 // Theme synchronization
93 syncThemeState();
94 themeObserver = new MutationObserver(syncThemeState);
95 themeObserver.observe(document.documentElement, {
96 attributes: true,
97 attributeFilter: ['class'],
98 });
99 });
100
101 onDestroy(() => {
102 cleanupKeyboard?.();
103 themeObserver?.disconnect();
104 themeObserver = null;
105 homepageState.reset();
106 });
107</script>
108
109<svelte:head>
110 <title>dumb://home</title>
111 <meta name="description" content="Dumber Browser - Homepage" />
112 {@html `<style>
113 html, body { margin: 0; padding: 0; }
114 html { background: var(--background, #0a0a0a); }
115 body { background: var(--background, #0a0a0a); color: var(--foreground, #e5e5e5); }
116 /* Disable GTK/WebKit default focus rings - we handle focus styling ourselves */
117 *:focus { outline: none !important; }
118 *:focus-visible { outline: none !important; }
119 button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; }
120 /* Enable touch scrolling for WebKit */
121 * { -webkit-overflow-scrolling: touch; }
122 </style>`}
123</svelte:head>
124
125<div class="homepage-shell">
126 <!-- Terminal Frame -->
127 <div class="terminal-frame">
128 <!-- Header -->
129 <header class="terminal-header">
130 <div class="header-left">
131 <span class="terminal-path">dumb://home</span>
132 <div class="header-tabs" role="tablist" aria-label="Homepage panels">
133 {#each panelTabs as tab, index (tab.id)}
134 <button
135 class="tab-button"
136 class:active={homepageState.activePanel === tab.id}
137 onclick={() => homepageState.setActivePanel(tab.id)}
138 onkeydown={(e) => {
139 switch (e.key) {
140 case 'Enter':
141 case ' ':
142 e.preventDefault();
143 homepageState.setActivePanel(tab.id);
144 break;
145 case 'ArrowLeft':
146 case 'ArrowUp':
147 e.preventDefault();
148 {
149 const prevIndex = index === 0 ? panelTabs.length - 1 : index - 1;
150 const prevTab = panelTabs[prevIndex];
151 if (prevTab) {
152 homepageState.setActivePanel(prevTab.id);
153 (e.currentTarget.parentElement?.children[prevIndex] as HTMLElement)?.focus();
154 }
155 }
156 break;
157 case 'ArrowRight':
158 case 'ArrowDown':
159 e.preventDefault();
160 {
161 const nextIndex = index === panelTabs.length - 1 ? 0 : index + 1;
162 const nextTab = panelTabs[nextIndex];
163 if (nextTab) {
164 homepageState.setActivePanel(nextTab.id);
165 (e.currentTarget.parentElement?.children[nextIndex] as HTMLElement)?.focus();
166 }
167 }
168 break;
169 }
170 }}
171 type="button"
172 role="tab"
173 aria-selected={homepageState.activePanel === tab.id}
174 tabindex={homepageState.activePanel === tab.id ? 0 : -1}
175 >
176 <tab.icon size={14} strokeWidth={2} class="tab-icon" />
177 <span class="tab-label">{tab.label}</span>
178 </button>
179 {/each}
180 </div>
181 </div>
182 <div class="header-right">
183 <button
184 class="action-button"
185 type="button"
186 onclick={() => homepageState.openCommandPalette()}
187 title="Command Palette (Ctrl+P or /)"
188 >
189 <Search size={14} strokeWidth={2} />
190 <span class="button-text">CMD</span>
191 <kbd class="kbd-hint">/</kbd>
192 </button>
193 <button
194 class="action-button"
195 type="button"
196 onclick={() => navigateWithViewTransition('dumb://config')}
197 title="Settings"
198 >
199 <Settings size={14} strokeWidth={2} />
200 <span class="button-text">CFG</span>
201 </button>
202 <button
203 class="theme-toggle"
204 type="button"
205 onclick={toggleTheme}
206 title={themeMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
207 aria-pressed={themeMode === 'dark'}
208 >
209 {#if themeMode === 'dark'}
210 <Moon size={18} strokeWidth={2} />
211 {:else}
212 <Sun size={18} strokeWidth={2} />
213 {/if}
214 </button>
215 </div>
216 </header>
217
218 <!-- Main Content Area -->
219 <main class="terminal-body scrollable">
220 {#if children}
221 {@render children()}
222 {:else}
223 <div class="empty-shell">
224 <span class="shell-prompt">$</span>
225 <span class="shell-cursor">_</span>
226 </div>
227 {/if}
228 </main>
229
230 <!-- Status Bar -->
231 <StatusBar />
232 </div>
233
234 <!-- Keyboard Hints Overlay -->
235 <KeyboardHints />
236
237 <!-- Command Palette Modal -->
238 {#if homepageState.commandPaletteOpen}
239 <CommandPalette />
240 {/if}
241
242 <!-- Confirmation Modal -->
243 {#if homepageState.confirmModalOpen}
244 <div
245 class="modal-overlay"
246 onclick={() => homepageState.cancelConfirm()}
247 onkeydown={(e) => { if (e.key === 'Escape') homepageState.cancelConfirm(); }}
248 role="button"
249 tabindex="0"
250 aria-label="Close confirmation modal"
251 >
252 <div class="confirm-modal" onclick={(e) => e.stopPropagation()} role="presentation">
253 <div class="modal-header">
254 <span class="modal-icon"></span>
255 <span class="modal-title">CONFIRM</span>
256 </div>
257 <p class="modal-message">{homepageState.confirmModalMessage}</p>
258 <div class="modal-actions">
259 <button
260 class="modal-btn modal-btn-cancel"
261 type="button"
262 onclick={() => homepageState.cancelConfirm()}
263 >
264 CANCEL
265 <kbd>Esc</kbd>
266 </button>
267 <button
268 class="modal-btn modal-btn-confirm"
269 type="button"
270 onclick={() => homepageState.confirmAction()}
271 >
272 CONFIRM
273 <kbd>Enter</kbd>
274 </button>
275 </div>
276 </div>
277 </div>
278 {/if}
279</div>
280
281<style>
282 :global(html),
283 :global(body) {
284 height: 100%;
285 }
286
287 :global(body) {
288 overflow: hidden;
289 overscroll-behavior: contain;
290 }
291
292 .homepage-shell {
293 height: 100vh;
294 min-height: 100vh;
295 display: flex;
296 flex-direction: column;
297 font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
298 line-height: 1.5;
299 color: var(--foreground);
300 background: var(--background);
301 overflow: hidden;
302 }
303
304 .terminal-frame {
305 flex: 1;
306 display: grid;
307 grid-template-rows: auto 1fr auto;
308 width: 100%;
309 height: 100%;
310 background: color-mix(in srgb, var(--card) 60%, var(--background) 40%);
311 }
312
313 /* Dark mode: subtle shadow */
314 :global(.dark) .terminal-frame {
315 box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5);
316 }
317
318 /* Header */
319 .terminal-header {
320 display: flex;
321 align-items: center;
322 justify-content: space-between;
323 gap: 1rem;
324 padding: 0.75rem 1rem;
325 border-bottom: 1px solid var(--border);
326 background: color-mix(in srgb, var(--background) 90%, var(--card) 10%);
327 position: sticky;
328 top: 0;
329 z-index: 5;
330 }
331
332 .header-left {
333 display: flex;
334 align-items: center;
335 gap: 1.5rem;
336 }
337
338 .terminal-path {
339 font-size: 0.8rem;
340 font-weight: 600;
341 color: var(--primary, #4ade80);
342 letter-spacing: 0.05em;
343 }
344
345 .header-tabs {
346 display: flex;
347 gap: 0.25rem;
348 }
349
350 .tab-button {
351 display: flex;
352 align-items: center;
353 gap: 0.4rem;
354 padding: 0.4rem 0.75rem;
355 font-size: 0.7rem;
356 font-weight: 500;
357 letter-spacing: 0.1em;
358 color: var(--muted-foreground);
359 background: transparent;
360 border: 1px solid transparent;
361 cursor: pointer;
362 transition: all 120ms ease;
363 }
364
365 .tab-button:hover {
366 color: var(--foreground);
367 background: color-mix(in srgb, var(--card) 50%, transparent);
368 }
369
370 .tab-button:focus-visible {
371 color: var(--foreground);
372 border-color: var(--primary, #4ade80);
373 background: color-mix(in srgb, var(--primary, #4ade80) 15%, transparent);
374 }
375
376 .tab-button.active {
377 color: var(--foreground);
378 background: var(--card);
379 border-color: var(--border);
380 }
381
382 .header-right {
383 display: flex;
384 align-items: center;
385 gap: 0.5rem;
386 }
387
388 .action-button {
389 display: flex;
390 align-items: center;
391 gap: 0.5rem;
392 padding: 0.4rem 0.75rem;
393 font-size: 0.7rem;
394 font-weight: 500;
395 letter-spacing: 0.08em;
396 color: var(--muted-foreground);
397 background: transparent;
398 border-width: 1px;
399 border-style: solid;
400 border-color: var(--border);
401 cursor: pointer;
402 transition: all 120ms ease;
403 }
404
405 .action-button:hover,
406 .action-button:focus-visible {
407 color: var(--foreground);
408 border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
409 background: color-mix(in srgb, var(--card) 30%, transparent);
410 outline: none;
411 }
412
413 .kbd-hint {
414 padding: 0.15rem 0.35rem;
415 font-size: 0.65rem;
416 color: var(--muted-foreground);
417 background: var(--background);
418 border-width: 1px;
419 border-style: solid;
420 border-color: var(--border);
421 margin-left: 0.25rem;
422 }
423
424 .theme-toggle {
425 display: flex;
426 align-items: center;
427 justify-content: center;
428 width: 2.25rem;
429 height: 2.25rem;
430 color: var(--muted-foreground);
431 background: transparent;
432 border-width: 1px;
433 border-style: solid;
434 border-color: var(--border);
435 cursor: pointer;
436 transition: all 120ms ease;
437 }
438
439 .theme-toggle:hover,
440 .theme-toggle:focus-visible {
441 color: var(--foreground);
442 border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
443 outline: none;
444 }
445
446 .theme-toggle :global(svg) {
447 flex-shrink: 0;
448 }
449
450 /* Main Content */
451 .terminal-body {
452 display: flex;
453 flex-direction: column;
454 min-height: 0;
455 overflow: hidden;
456 background: var(--background);
457 }
458
459 .terminal-body.scrollable {
460 overflow-y: auto;
461 -webkit-overflow-scrolling: touch;
462 touch-action: pan-y;
463 }
464
465 .empty-shell {
466 flex: 1;
467 display: flex;
468 align-items: center;
469 justify-content: center;
470 gap: 0.5rem;
471 color: var(--muted-foreground);
472 font-size: 1.25rem;
473 }
474
475 .shell-prompt {
476 color: var(--primary, #4ade80);
477 }
478
479 .shell-cursor {
480 animation: blink 1s step-end infinite;
481 }
482
483 @keyframes blink {
484 50% { opacity: 0; }
485 }
486
487 /* Modal Overlay */
488 .modal-overlay {
489 position: fixed;
490 inset: 0;
491 z-index: 100;
492 display: flex;
493 align-items: center;
494 justify-content: center;
495 background: rgb(0 0 0 / 0.7);
496 backdrop-filter: blur(4px);
497 animation: fade-in 150ms ease;
498 }
499
500 @keyframes fade-in {
501 from { opacity: 0; }
502 }
503
504 .confirm-modal {
505 width: 100%;
506 max-width: 420px;
507 margin: 1rem;
508 background: var(--card);
509 border-width: 1px;
510 border-style: solid;
511 border-color: var(--border);
512 box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
513 animation: modal-in 200ms ease;
514 }
515
516 @keyframes modal-in {
517 from {
518 opacity: 0;
519 transform: scale(0.95) translateY(-8px);
520 }
521 }
522
523 .modal-header {
524 display: flex;
525 align-items: center;
526 gap: 0.6rem;
527 padding: 0.85rem 1rem;
528 border-bottom-width: 1px;
529 border-bottom-style: solid;
530 border-bottom-color: var(--border);
531 background: color-mix(in srgb, var(--background) 80%, transparent);
532 }
533
534 .modal-icon {
535 font-size: 1rem;
536 color: var(--warning);
537 }
538
539 .modal-title {
540 font-size: 0.75rem;
541 font-weight: 600;
542 letter-spacing: 0.1em;
543 color: var(--foreground);
544 }
545
546 .modal-message {
547 margin: 0;
548 padding: 1.25rem 1rem;
549 font-size: 0.85rem;
550 color: var(--foreground);
551 line-height: 1.6;
552 }
553
554 .modal-actions {
555 display: flex;
556 gap: 0.5rem;
557 padding: 0.85rem 1rem;
558 border-top-width: 1px;
559 border-top-style: solid;
560 border-top-color: var(--border);
561 background: color-mix(in srgb, var(--background) 50%, transparent);
562 }
563
564 .modal-btn {
565 flex: 1;
566 display: flex;
567 align-items: center;
568 justify-content: center;
569 gap: 0.6rem;
570 padding: 0.6rem 1rem;
571 font-size: 0.72rem;
572 font-weight: 500;
573 letter-spacing: 0.08em;
574 border-width: 1px;
575 border-style: solid;
576 border-color: var(--border);
577 cursor: pointer;
578 transition: all 120ms ease;
579 }
580
581 .modal-btn kbd {
582 padding: 0.1rem 0.3rem;
583 font-size: 0.6rem;
584 background: var(--background);
585 border-width: 1px;
586 border-style: solid;
587 border-color: var(--border);
588 opacity: 0.7;
589 }
590
591 .modal-btn-cancel {
592 color: var(--muted-foreground);
593 background: transparent;
594 }
595
596 .modal-btn-cancel:hover {
597 color: var(--foreground);
598 background: color-mix(in srgb, var(--card) 40%, transparent);
599 }
600
601 .modal-btn-confirm {
602 color: var(--destructive-foreground);
603 background: var(--destructive);
604 border-color: color-mix(in srgb, var(--destructive) 80%, black 20%);
605 }
606
607 .modal-btn-confirm:hover {
608 background: color-mix(in srgb, var(--destructive) 85%, white 15%);
609 }
610
611 /* Responsive */
612 @media (max-width: 768px) {
613 .terminal-header {
614 flex-wrap: wrap;
615 gap: 0.75rem;
616 }
617
618 .header-left {
619 flex-wrap: wrap;
620 gap: 0.75rem;
621 }
622
623 .header-tabs {
624 order: 10;
625 width: 100%;
626 }
627
628 .tab-button {
629 flex: 1;
630 justify-content: center;
631 }
632
633 .action-button .button-text {
634 display: none;
635 }
636 }
637</style>