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