1<script lang="ts">
2 import { onMount } from 'svelte';
3 import Footer from '$components/Footer.svelte';
4 // Icons disabled for investigation: removed @lucide/svelte imports
5
6 interface HistoryItem {
7 id: number;
8 url: string;
9 title: string;
10 favicon_url?: string;
11 visit_count: number;
12 last_visited: string;
13 created_at: string;
14 }
15
16 interface Shortcut {
17 key: string;
18 description: string;
19 }
20
21 interface HistoryStats {
22 total_entries: number;
23 recent_count: number;
24 oldest_entry?: string;
25 newest_entry?: string;
26 }
27
28 interface SearchShortcut {
29 url: string;
30 description: string;
31 }
32
33 let historyItems = $state<HistoryItem[]>([]);
34 let topVisited = $state<HistoryItem[]>([]);
35 let shortcuts = $state<Shortcut[]>([]);
36 let stats = $state<HistoryStats | null>(null);
37 let historyLoading = $state(true);
38 let topVisitedLoading = $state(true);
39 let shortcutsLoading = $state(true);
40 let statsLoading = $state(true);
41 let searchShortcuts = $state<Record<string, SearchShortcut>>({});
42 let searchShortcutsLoading = $state(true);
43 let deletingIds = $state<number[]>([]);
44 let historyOffset = $state(0);
45 let hasMoreHistory = $state(true);
46 let loadingMoreHistory = $state(false);
47 const MIN_VISIT_COUNT_FOR_QUICK_ACCESS = 2;
48 type ThemeMode = "light" | "dark";
49 const THEME_STORAGE_KEY = "dumber.theme";
50 let themeMode = $state<ThemeMode>("dark");
51 let themeObserver: MutationObserver | null = null;
52 const INITIAL_HISTORY_DISPLAY = 15;
53 let showAllHistory = $state(false);
54 const PINNED_SITES_STORAGE_KEY = "dumber.pinnedSites";
55 let pinnedSites = $state<Set<string>>(new Set());
56 let displayedHistoryItems = $derived(
57 showAllHistory ? historyItems : historyItems.slice(0, INITIAL_HISTORY_DISPLAY)
58 );
59
60 // Function for checking if an item is being deleted
61 const isDeleting = (id: number) => deletingIds.includes(id);
62
63 // Pinned sites management
64 const loadPinnedSites = () => {
65 try {
66 const stored = localStorage.getItem(PINNED_SITES_STORAGE_KEY);
67 if (stored) {
68 const urls = JSON.parse(stored) as string[];
69 pinnedSites = new Set(urls);
70 }
71 } catch (error) {
72 console.warn("[homepage] Failed to load pinned sites", error);
73 pinnedSites = new Set();
74 }
75 };
76
77 const savePinnedSites = () => {
78 try {
79 const urls = Array.from(pinnedSites);
80 localStorage.setItem(PINNED_SITES_STORAGE_KEY, JSON.stringify(urls));
81 } catch (error) {
82 console.warn("[homepage] Failed to save pinned sites", error);
83 }
84 };
85
86 const isPinned = (url: string): boolean => pinnedSites.has(url);
87
88 const togglePin = (url: string) => {
89 if (pinnedSites.has(url)) {
90 pinnedSites.delete(url);
91 } else {
92 pinnedSites.add(url);
93 }
94 pinnedSites = new Set(pinnedSites); // Trigger reactivity
95 savePinnedSites();
96 };
97
98 // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
99 const initializeShortcuts = () => {
100 shortcuts = [
101 // Browser shortcuts
102 {
103 key: 'Ctrl+L',
104 description: 'Focus address bar'
105 },
106 {
107 key: 'Ctrl+F',
108 description: 'Find in page'
109 },
110 {
111 key: 'Ctrl+Shift+C',
112 description: 'Copy URL'
113 },
114 {
115 key: 'Ctrl+R / F5',
116 description: 'Reload page'
117 },
118 {
119 key: 'Ctrl+Shift+R',
120 description: 'Hard reload'
121 },
122 {
123 key: 'F12',
124 description: 'DevTools'
125 },
126 {
127 key: 'Ctrl+← / Ctrl+→',
128 description: 'Navigate back/forward'
129 },
130 {
131 key: 'Ctrl+0',
132 description: 'Reset zoom'
133 },
134 {
135 key: 'Ctrl++ / Ctrl+-',
136 description: 'Zoom in/out'
137 },
138 // Workspace pane mode
139 {
140 key: 'Ctrl+P',
141 description: 'Enter pane mode'
142 },
143 {
144 key: '→ / R (pane mode)',
145 description: 'Split pane right'
146 },
147 {
148 key: '← / L (pane mode)',
149 description: 'Split pane left'
150 },
151 {
152 key: '↑ / U (pane mode)',
153 description: 'Split pane up'
154 },
155 {
156 key: '↓ / D (pane mode)',
157 description: 'Split pane down'
158 },
159 {
160 key: 'X (pane mode)',
161 description: 'Close current pane'
162 },
163 {
164 key: 'Enter (pane mode)',
165 description: 'Confirm action'
166 },
167 {
168 key: 'Escape (pane mode)',
169 description: 'Exit pane mode'
170 },
171 // Workspace navigation
172 {
173 key: 'Alt+Arrow Keys',
174 description: 'Navigate between panes'
175 }
176 ];
177 shortcutsLoading = false;
178 };
179
180 // Setup message bridge callbacks (once)
181 let callbacksInitialized = false;
182 const pendingRequests = new Map();
183
184 const initializeCallbacks = () => {
185 if (callbacksInitialized) return;
186
187 (window as any).__dumber_history_recent = (data: any[], requestId?: string) => {
188 const request = pendingRequests.get(requestId || 'default');
189 if (request) {
190 request.resolve(data);
191 pendingRequests.delete(requestId || 'default');
192 }
193 };
194
195 (window as any).__dumber_history_error = (error: string, requestId?: string) => {
196 const request = pendingRequests.get(requestId || 'default');
197 if (request) {
198 request.reject(new Error(error));
199 pendingRequests.delete(requestId || 'default');
200 }
201 };
202
203 // Omnibox suggestions callback - forward to injected script via DOM event bridge
204 let _pendingSuggestions: any[] | null = null;
205 (window as any).__dumber_omnibox_suggestions = (suggestions: any[]) => {
206 try {
207 console.log('🎯 [HOMEPAGE] Received omnibox suggestions:', suggestions?.length || 0, 'items');
208 // Dispatch an event that injected-world listener can consume
209 const evt = new CustomEvent('dumber:omnibox-suggestions', {
210 detail: { suggestions }
211 });
212 document.dispatchEvent(evt);
213 _pendingSuggestions = null; // clear cache on successful dispatch
214 } catch (err) {
215 console.warn('⚠️ [HOMEPAGE] Failed to dispatch suggestions event, caching:', err);
216 _pendingSuggestions = suggestions;
217 }
218 };
219
220 // If omnibox gets ready later, re-send any pending suggestions
221 document.addEventListener('dumber:omnibox-ready', () => {
222 if (_pendingSuggestions && Array.isArray(_pendingSuggestions)) {
223 try {
224 const evt = new CustomEvent('dumber:omnibox-suggestions', {
225 detail: { suggestions: _pendingSuggestions }
226 });
227 document.dispatchEvent(evt);
228 _pendingSuggestions = null;
229 console.log('🔄 [HOMEPAGE] Re-dispatched pending suggestions after omnibox-ready');
230 } catch (e) {
231 console.warn('⚠️ [HOMEPAGE] Failed to re-dispatch pending suggestions:', e);
232 }
233 }
234 });
235
236 callbacksInitialized = true;
237 };
238
239 const sendHistoryRequest = (type: string, params: any): Promise<any[]> => {
240 initializeCallbacks();
241
242 const requestId = `${type}_${Date.now()}_${Math.random()}`;
243
244 return new Promise((resolve, reject) => {
245 pendingRequests.set(requestId, { resolve, reject });
246
247 // Timeout after 10 seconds
248 setTimeout(() => {
249 if (pendingRequests.has(requestId)) {
250 pendingRequests.delete(requestId);
251 reject(new Error('Request timeout'));
252 }
253 }, 10000);
254
255 try {
256 const bridge = (window as any).webkit?.messageHandlers?.dumber;
257 if (bridge && typeof bridge.postMessage === 'function') {
258 bridge.postMessage(JSON.stringify({
259 ...params,
260 type,
261 requestId
262 }));
263 } else {
264 reject(new Error('WebKit message handler not available'));
265 }
266 } catch (error) {
267 pendingRequests.delete(requestId);
268 reject(error);
269 }
270 });
271 };
272
273 // Fetch recent history via message bridge
274 const fetchHistory = async () => {
275 try {
276 const data = await sendHistoryRequest('history_recent', {
277 limit: 20,
278 offset: 0
279 });
280
281 historyItems = Array.isArray(data) ? data : [];
282 historyOffset = historyItems.length;
283 hasMoreHistory = data.length === 20;
284 } catch (error) {
285 console.error('Error fetching history:', error);
286 historyItems = [];
287 hasMoreHistory = false;
288 } finally {
289 historyLoading = false;
290 }
291 };
292
293 // Load more history items for infinite scroll
294 const loadMoreHistory = async () => {
295 if (loadingMoreHistory || !hasMoreHistory) return;
296
297 loadingMoreHistory = true;
298 try {
299 const data = await sendHistoryRequest('history_recent', {
300 limit: 20,
301 offset: historyOffset
302 });
303
304 if (Array.isArray(data) && data.length > 0) {
305 historyItems = [...historyItems, ...data];
306 historyOffset += data.length;
307 hasMoreHistory = data.length === 20; // If we got full batch, there might be more
308 } else {
309 hasMoreHistory = false;
310 }
311 } catch (error) {
312 console.error('Error fetching more history:', error);
313 hasMoreHistory = false;
314 } finally {
315 loadingMoreHistory = false;
316 }
317 };
318
319 // Navigate to a URL when history item is clicked
320 const navigateTo = (url: string) => {
321 window.location.href = url;
322 };
323
324 // Delete a history entry
325 const deleteHistoryEntry = async (id: number, event: Event) => {
326 event.stopPropagation(); // Prevent navigation
327
328 // Add to deleting array for animation
329 if (!deletingIds.includes(id)) {
330 deletingIds = [...deletingIds, id];
331 }
332
333 try {
334 // Set up response handlers
335 (window as any).__dumber_history_deleted = async () => {
336 // Wait a bit for the fade animation to complete
337 await new Promise(resolve => setTimeout(resolve, 200));
338
339 // Reset pagination and refresh history after successful deletion
340 historyOffset = 0;
341 hasMoreHistory = true;
342 await fetchHistory();
343 await fetchStats();
344 await fetchTopVisited();
345
346 // Remove from deleting array after refresh
347 deletingIds = deletingIds.filter(i => i !== id);
348 };
349
350 (window as any).__dumber_history_error = (error: string) => {
351 console.error('Error deleting history entry:', error);
352 // Remove from deleting array if failed
353 deletingIds = deletingIds.filter(i => i !== id);
354 };
355
356 // Send message to Go backend
357 const bridge = (window as any).webkit?.messageHandlers?.dumber;
358 if (bridge && typeof bridge.postMessage === 'function') {
359 bridge.postMessage(JSON.stringify({
360 type: 'history_delete',
361 historyId: id.toString()
362 }));
363 } else {
364 throw new Error('WebKit message handler not available');
365 }
366 } catch (error) {
367 console.error('Error sending delete request:', error);
368 // Remove from deleting array if failed
369 deletingIds = deletingIds.filter(i => i !== id);
370 }
371 };
372
373 // Get domain from URL
374 const getDomain = (url: string): string => {
375 try {
376 return new URL(url).hostname;
377 } catch {
378 return url;
379 }
380 };
381
382 const getDisplayTitle = (item: HistoryItem): string => {
383 if (!item) return '';
384 const trimmed = item.title?.trim();
385 if (trimmed) return trimmed;
386 const domain = getDomain(item.url);
387 return domain || item.url;
388 };
389
390 const highlightDomainInUrl = (url: string): string => {
391 try {
392 const urlObj = new URL(url);
393 const domain = urlObj.hostname;
394 const domainStart = url.indexOf(domain);
395
396 if (domainStart === -1) return url;
397
398 const beforeDomain = url.slice(0, domainStart);
399 const afterDomain = url.slice(domainStart + domain.length);
400
401 return `${beforeDomain}<span class="history-url-domain">${domain}</span>${afterDomain}`;
402 } catch {
403 return url;
404 }
405 };
406
407 // Format relative time
408 const formatTime = (timestamp: string): string => {
409 try {
410 const date = new Date(timestamp);
411 const now = new Date();
412 const diff = now.getTime() - date.getTime();
413
414 const minutes = Math.floor(diff / (1000 * 60));
415 const hours = Math.floor(diff / (1000 * 60 * 60));
416 const days = Math.floor(diff / (1000 * 60 * 60 * 24));
417
418 if (minutes < 1) return 'Just now';
419 if (minutes < 60) return `${minutes}m ago`;
420 if (hours < 24) return `${hours}h ago`;
421 if (days < 7) return `${days}d ago`;
422
423 return date.toLocaleDateString();
424 } catch {
425 return '';
426 }
427 };
428
429 const formatCalendarDate = (timestamp?: string): string => {
430 if (!timestamp) return '—';
431 try {
432 const date = new Date(timestamp);
433 return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
434 } catch {
435 return '—';
436 }
437 };
438
439 const formatVisitLabel = (count: number): string => {
440 if (!count || count <= 0) return 'Open';
441 if (count >= 1000) {
442 const value = count / 1000;
443 const formatted = value >= 10 ? Math.round(value).toString() : value.toFixed(1).replace(/\.0$/, '');
444 return `${formatted}k visits`;
445 }
446 return `${count} ${count === 1 ? 'visit' : 'visits'}`;
447 };
448
449 const buildQuickAccess = (): HistoryItem[] => {
450 const picks: HistoryItem[] = [];
451 const seen = new Set<string>();
452
453 const pushItem = (item: HistoryItem | null | undefined) => {
454 if (!item) return;
455 if (seen.has(item.url)) return;
456 seen.add(item.url);
457 picks.push(item);
458 };
459
460 // First, add pinned sites (no visit count requirement for pinned)
461 for (const item of topVisited ?? []) {
462 if (pinnedSites.has(item.url)) {
463 pushItem(item);
464 }
465 }
466
467 for (const item of historyItems ?? []) {
468 if (pinnedSites.has(item.url)) {
469 pushItem(item);
470 }
471 }
472
473 // Then add most visited sites (non-pinned, with minimum visit count)
474 for (const item of topVisited ?? []) {
475 if (!pinnedSites.has(item.url) && item.visit_count >= MIN_VISIT_COUNT_FOR_QUICK_ACCESS) {
476 pushItem(item);
477 }
478 }
479
480 // Finally add recent history (non-pinned, with minimum visit count)
481 for (const item of historyItems ?? []) {
482 if (!pinnedSites.has(item.url) && item.visit_count >= MIN_VISIT_COUNT_FOR_QUICK_ACCESS) {
483 pushItem(item);
484 }
485 }
486
487 return picks;
488 };
489
490 let quickAccess = $derived<HistoryItem[]>((pinnedSites.size, buildQuickAccess()));
491 let quickAccessLoading = $derived(topVisitedLoading || historyLoading);
492 interface LatestVisitMeta {
493 entry: HistoryItem;
494 relative: string;
495 absolute: string;
496 domain: string;
497 title: string;
498 }
499
500 const latestVisitInfo = $derived<LatestVisitMeta | null>((() => {
501 if (!historyItems.length) return null;
502
503 let latest: HistoryItem | null = null;
504 let latestEpoch = Number.NEGATIVE_INFINITY;
505
506 for (const entry of historyItems) {
507 const epoch = Number(new Date(entry.last_visited));
508 if (!Number.isFinite(epoch)) continue;
509 if (epoch > latestEpoch) {
510 latest = entry;
511 latestEpoch = epoch;
512 }
513 }
514
515 if (!latest) return null;
516
517 return {
518 entry: latest,
519 relative: formatTime(latest.last_visited),
520 absolute: formatCalendarDate(latest.last_visited),
521 domain: getDomain(latest.url),
522 title: getDisplayTitle(latest)
523 };
524 })());
525
526 const persistTheme = (mode: ThemeMode) => {
527 try {
528 localStorage.setItem(THEME_STORAGE_KEY, mode);
529 } catch (error) {
530 console.warn("[homepage] Failed to persist theme preference", error);
531 }
532 };
533
534 const applyTheme = (mode: ThemeMode) => {
535 themeMode = mode;
536
537 const manager = (window as any).__dumber_color_scheme_manager as
538 | { setUserPreference?: (theme: ThemeMode) => void }
539 | undefined;
540
541 if (manager?.setUserPreference) {
542 manager.setUserPreference(mode);
543 return;
544 }
545
546 if (window.__dumber_setTheme) {
547 window.__dumber_setTheme(mode);
548 persistTheme(mode);
549 return;
550 }
551
552 if (mode === "dark") {
553 document.documentElement.classList.add("dark");
554 } else {
555 document.documentElement.classList.remove("dark");
556 }
557 persistTheme(mode);
558 };
559
560 const toggleTheme = () => {
561 const nextMode = themeMode === "dark" ? "light" : "dark";
562 applyTheme(nextMode);
563 };
564
565 // Fetch history stats via message bridge
566 const fetchStats = async () => {
567 try {
568 // Set up response handler
569 (window as any).__dumber_history_stats = (data: any) => {
570 stats = data;
571 statsLoading = false;
572 };
573
574 (window as any).__dumber_history_error = (error: string) => {
575 console.error('Error fetching stats:', error);
576 stats = null;
577 statsLoading = false;
578 };
579
580 // Send message to Go backend
581 const bridge = (window as any).webkit?.messageHandlers?.dumber;
582 if (bridge && typeof bridge.postMessage === 'function') {
583 bridge.postMessage(JSON.stringify({
584 type: 'history_stats'
585 }));
586 } else {
587 throw new Error('WebKit message handler not available');
588 }
589 } catch (error) {
590 console.error('Error sending stats request:', error);
591 stats = null;
592 statsLoading = false;
593 }
594 };
595
596 // Fetch top visited sites via message bridge
597 const fetchTopVisited = async () => {
598 try {
599 // Set up response handler
600 (window as any).__dumber_history_search = (data: any[]) => {
601 // Sort by visit_count in descending order and take top 5
602 const sortedData = Array.isArray(data) ?
603 data.sort((a, b) => (b.visit_count || 0) - (a.visit_count || 0)).slice(0, 5) : [];
604 topVisited = sortedData;
605 topVisitedLoading = false;
606 };
607
608 (window as any).__dumber_history_error = (error: string) => {
609 console.error('Error fetching top visited:', error);
610 topVisited = [];
611 topVisitedLoading = false;
612 };
613
614 // Send message to Go backend
615 const bridge = (window as any).webkit?.messageHandlers?.dumber;
616 if (bridge && typeof bridge.postMessage === 'function') {
617 bridge.postMessage(JSON.stringify({
618 type: 'history_search',
619 q: '',
620 limit: 50 // Get more results to sort properly
621 }));
622 } else {
623 throw new Error('WebKit message handler not available');
624 }
625 } catch (error) {
626 console.error('Error sending top visited request:', error);
627 topVisited = [];
628 topVisitedLoading = false;
629 }
630 };
631
632 // Fetch search shortcuts via message bridge
633 const fetchSearchShortcuts = async () => {
634 try {
635 // Set up response handler
636 (window as any).__dumber_search_shortcuts = (data: unknown) => {
637 try {
638 if (typeof data !== 'object' || data === null) {
639 throw new Error('Invalid shortcuts data');
640 }
641 const dataObj = data as Record<string, unknown>;
642 const normalized: Record<string, SearchShortcut> = {};
643 for (const [key, value] of Object.entries(dataObj)) {
644 const v = value as Record<string, unknown>;
645 normalized[key] = {
646 url: (v.url ?? v.URL ?? '') as string,
647 description: (v.description ?? v.Description ?? '') as string,
648 };
649 }
650 searchShortcuts = normalized;
651 searchShortcutsLoading = false;
652 } catch (error) {
653 console.error('Failed to process search shortcuts:', error);
654 searchShortcuts = {};
655 searchShortcutsLoading = false;
656 }
657 };
658
659 // Send message to Go backend
660 const bridge = (window as any).webkit?.messageHandlers?.dumber;
661 if (bridge && typeof bridge.postMessage === 'function') {
662 bridge.postMessage(JSON.stringify({
663 type: 'get_search_shortcuts'
664 }));
665 } else {
666 throw new Error('WebKit message handler not available');
667 }
668 } catch (error) {
669 console.error('Error fetching search shortcuts:', error);
670 searchShortcuts = {};
671 searchShortcutsLoading = false;
672 }
673 };
674
675 // Intersection Observer for infinite scroll
676 let historyScrollSentinel = $state<HTMLElement | null>(null);
677
678 const setupInfiniteScroll = () => {
679 if (!historyScrollSentinel) return;
680
681 const observer = new IntersectionObserver(
682 (entries) => {
683 const entry = entries[0];
684 if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) {
685 loadMoreHistory();
686 }
687 },
688 {
689 threshold: 0.1,
690 rootMargin: '50px'
691 }
692 );
693
694 observer.observe(historyScrollSentinel);
695
696 return () => observer.disconnect();
697 };
698
699 // Helper to check if text overflows and apply truncation class
700 const checkOverflow = (node: HTMLElement) => {
701 const check = () => {
702 // Add 1px tolerance for sub-pixel rendering differences
703 // This prevents false positives from rounding errors
704 const isOverflowing = node.scrollWidth > (node.clientWidth + 1);
705
706 if (isOverflowing) {
707 node.classList.add('truncated');
708 } else {
709 node.classList.remove('truncated');
710 }
711 };
712
713 // Delay initial check to ensure layout is complete
714 requestAnimationFrame(() => {
715 requestAnimationFrame(check);
716 });
717
718 const resizeObserver = new ResizeObserver(() => {
719 requestAnimationFrame(check);
720 });
721
722 resizeObserver.observe(node);
723
724 return {
725 destroy() {
726 resizeObserver.disconnect();
727 }
728 };
729 };
730
731 onMount(() => {
732 loadPinnedSites();
733 initializeShortcuts();
734 fetchHistory().then(() => {
735 // Setup infinite scroll after initial history is loaded
736 setTimeout(setupInfiniteScroll, 100);
737 });
738 fetchStats();
739 fetchTopVisited();
740 fetchSearchShortcuts();
741
742 // Theme synchronization
743 const syncThemeState = () => {
744 themeMode = document.documentElement.classList.contains("dark")
745 ? "dark"
746 : "light";
747 };
748 syncThemeState();
749 themeObserver = new MutationObserver(syncThemeState);
750 themeObserver.observe(document.documentElement, {
751 attributes: true,
752 attributeFilter: ["class"],
753 });
754
755 return () => {
756 themeObserver?.disconnect();
757 themeObserver = null;
758 };
759 });
760
761
762</script>
763
764<svelte:head>
765 <title>Dumber Browser - Homepage</title>
766 <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" />
767
768 {@html `<style>
769 html { background: #0a0a0a; }
770 body { background: #0a0a0a; color: #e5e5e5; }
771 html.light { background: #ffffff; }
772 html.light body { background: #ffffff; color: #171717; }
773 </style>`}
774
775 {@html `<script>
776 (function() {
777 const theme = localStorage.getItem('dumber.theme');
778 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
779 const shouldBeLight = theme === 'light' || (theme !== 'dark' && !prefersDark);
780 if (shouldBeLight) {
781 document.documentElement.classList.add('light');
782 }
783 })();
784 </script>`}
785</svelte:head>
786
787<div class="homepage-shell">
788 <div class="terminal-frame">
789 <header class="terminal-header">
790 <div class="terminal-heading">
791 <span class="terminal-path">dumb://home</span>
792 <span class="terminal-meta">
793 {statsLoading ? 'syncing…' : `${stats?.recent_count ?? 0} recent`}
794 · {historyItems.length} entries
795 · {pinnedSites.size} pinned
796 </span>
797 </div>
798 <button
799 class="theme-toggle-button"
800 type="button"
801 aria-pressed={themeMode === "dark"}
802 title={themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
803 onclick={toggleTheme}
804 >
805 <span class="sr-only">
806 {themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
807 </span>
808 {#if themeMode === "dark"}
809 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
810 <path
811 fill="currentColor"
812 fill-rule="evenodd"
813 clip-rule="evenodd"
814 d="M17.293 13.293A8 8 0 0 1 10.707 2.997a8.001 8.001 0 1 0 6.586 10.296z"
815 />
816 </svg>
817 {:else}
818 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
819 <circle cx="12" cy="12" r="5" fill="currentColor" />
820 <g stroke="currentColor" stroke-width="2" stroke-linecap="round">
821 <line x1="12" y1="1" x2="12" y2="4" />
822 <line x1="12" y1="20" x2="12" y2="23" />
823 <line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
824 <line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
825 <line x1="1" y1="12" x2="4" y2="12" />
826 <line x1="20" y1="12" x2="23" y2="12" />
827 <line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
828 <line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
829 </g>
830 </svg>
831 {/if}
832 </button>
833 </header>
834
835 <div class="terminal-body">
836 <div class="terminal-status-row">
837 <div class="status-chip">
838 <span class="chip-label">history</span>
839 <span class="chip-value">{historyItems.length}</span>
840 </div>
841 <div class="status-chip">
842 <span class="chip-label">pinned</span>
843 <span class="chip-value">{pinnedSites.size}</span>
844 </div>
845 <div class="status-chip">
846 <span class="chip-label">quick-links</span>
847 <span class="chip-value">{quickAccess.length}</span>
848 </div>
849 {#if stats && stats.total_entries}
850 <div class="status-chip">
851 <span class="chip-label">stored</span>
852 <span class="chip-value">{stats.total_entries}</span>
853 </div>
854 {/if}
855 </div>
856
857 <div
858 class="last-visit-banner"
859 role="button"
860 tabindex={latestVisitInfo ? 0 : -1}
861 onclick={() => latestVisitInfo && navigateTo(latestVisitInfo.entry.url)}
862 onkeydown={(event) => {
863 if (event.key === 'Enter' && latestVisitInfo) {
864 navigateTo(latestVisitInfo.entry.url);
865 }
866 }}
867 aria-label={latestVisitInfo ? `Open ${latestVisitInfo.title}` : 'No recorded visits'}
868 >
869 <span class="banner-label">last-visit</span>
870 {#if latestVisitInfo}
871 <span class="banner-title" use:checkOverflow>
872 {latestVisitInfo.title}
873 </span>
874 <span class="banner-domain">{latestVisitInfo.domain}</span>
875 <span class="banner-time">{latestVisitInfo.relative}</span>
876 <span class="banner-absolute">{latestVisitInfo.absolute}</span>
877 {:else}
878 <span class="banner-empty">none recorded yet</span>
879 {/if}
880 </div>
881
882 <div class="terminal-grid main-panels">
883 <section class="history-panel terminal-pane">
884 <div class="panel-header">
885 <h2 class="panel-title">Recent History</h2>
886 <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>
887 </div>
888 <div class="panel-body history-body">
889 {#if historyLoading}
890 <div class="loading">Loading history...</div>
891 {:else if historyItems.length === 0}
892 <div class="empty-state">
893 <h3>No history yet</h3>
894 <p>Start browsing to see your recent history here.</p>
895 </div>
896 {:else}
897 <div class="history-list">
898 {#each displayedHistoryItems as item (item.id)}
899 <div
900 class="history-item"
901 class:deleting={isDeleting(item.id)}
902 role="button"
903 tabindex="0"
904 onclick={() => navigateTo(item.url)}
905 onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
906 >
907 <div class="history-item-leading">
908 {#if item.favicon_url}
909 <img
910 src={item.favicon_url}
911 alt=""
912 class="history-favicon-img"
913 onerror={(e) => {
914 const target = e.target as HTMLImageElement;
915 if (target) {
916 target.style.display = 'none';
917 const fallback = target.nextElementSibling as HTMLElement;
918 if (fallback) fallback.style.display = 'flex';
919 }
920 }}
921 onload={(e) => {
922 const target = e.target as HTMLImageElement;
923 if (target) {
924 const fallback = target.nextElementSibling as HTMLElement;
925 if (fallback) fallback.style.display = 'none';
926 }
927 }}
928 />
929 <div class="history-favicon-fallback" style="display: none;">
930 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
931 <circle cx="12" cy="12" r="10" />
932 <path d="M2 12h20" />
933 </svg>
934 </div>
935 {:else}
936 <div class="history-favicon-fallback">
937 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
938 <circle cx="12" cy="12" r="10" />
939 <path d="M2 12h20" />
940 </svg>
941 </div>
942 {/if}
943 </div>
944 <div class="history-item-content">
945 <div class="history-item-main">
946 <div class="history-title" use:checkOverflow>
947 {getDisplayTitle(item)}
948 </div>
949 <div class="history-time">{formatTime(item.last_visited)}</div>
950 </div>
951 <div class="history-url" use:checkOverflow>
952 {@html highlightDomainInUrl(item.url)}
953 </div>
954 </div>
955 <div class="history-actions">
956 <div
957 class="history-pin"
958 role="button"
959 tabindex="0"
960 onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
961 onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
962 title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
963 >
964 {#if isPinned(item.url)}
965 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
966 <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
967 </svg>
968 {:else}
969 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">
970 <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
971 </svg>
972 {/if}
973 </div>
974 <div
975 class="history-delete"
976 role="button"
977 tabindex="0"
978 onclick={(e) => deleteHistoryEntry(item.id, e)}
979 onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
980 title="Delete this entry"
981 >
982 <span aria-hidden="true">×</span>
983 </div>
984 </div>
985 </div>
986 {/each}
987
988 {#if !showAllHistory && historyItems.length > INITIAL_HISTORY_DISPLAY}
989 <button
990 class="show-more-button"
991 onclick={() => showAllHistory = true}
992 type="button"
993 >
994 Show {historyItems.length - INITIAL_HISTORY_DISPLAY} more items
995 </button>
996 {:else if showAllHistory && hasMoreHistory}
997 <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
998 {#if loadingMoreHistory}
999 <div class="loading-more">Loading more history...</div>
1000 {/if}
1001 {/if}
1002 </div>
1003 {/if}
1004 </div>
1005 </section>
1006
1007 <section class="quick-access-panel terminal-pane">
1008 <div class="panel-header">
1009 <h2 class="panel-title">Jump back in</h2>
1010 <p class="panel-subtitle">Direct access to the sites you like the most.</p>
1011 </div>
1012 <div class="panel-body quick-access-body">
1013 {#if quickAccessLoading}
1014 <div class="loading">Preparing your shortcuts...</div>
1015 {:else if quickAccess.length === 0}
1016 <div class="empty-state compact">
1017 <h3>No quick links yet</h3>
1018 <p>Browse a few sites and we'll surface your frequent destinations here.</p>
1019 </div>
1020 {:else}
1021 <div class="quick-access-grid-compact">
1022 {#each quickAccess as item (item.id)}
1023 <div
1024 class="quick-access-item-compact"
1025 role="button"
1026 tabindex="0"
1027 onclick={() => navigateTo(item.url)}
1028 onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
1029 >
1030 <div class="quick-access-favicon-compact">
1031 {#if item.favicon_url}
1032 <img
1033 src={item.favicon_url}
1034 alt=""
1035 class="quick-access-favicon-img-compact"
1036 onerror={(e) => {
1037 const target = e.target as HTMLImageElement;
1038 if (target) {
1039 target.style.display = 'none';
1040 const fallback = target.nextElementSibling as HTMLElement;
1041 if (fallback) fallback.style.display = 'flex';
1042 }
1043 }}
1044 onload={(e) => {
1045 const target = e.target as HTMLImageElement;
1046 if (target) {
1047 const fallback = target.nextElementSibling as HTMLElement;
1048 if (fallback) fallback.style.display = 'none';
1049 }
1050 }}
1051 />
1052 <div class="quick-access-fallback-compact" style="display: none;">
1053 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1054 <circle cx="12" cy="12" r="10" />
1055 <path d="M2 12h20" />
1056 </svg>
1057 </div>
1058 {:else}
1059 <div class="quick-access-fallback-compact">
1060 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1061 <circle cx="12" cy="12" r="10" />
1062 <path d="M2 12h20" />
1063 </svg>
1064 </div>
1065 {/if}
1066 </div>
1067 <div class="quick-access-meta-compact">
1068 <div class="quick-access-title-compact" use:checkOverflow>
1069 {getDisplayTitle(item)}
1070 </div>
1071 <div class="quick-access-domain-compact">{getDomain(item.url)}</div>
1072 </div>
1073 <div class="quick-access-actions-compact">
1074 <div
1075 class="quick-access-pin-compact"
1076 role="button"
1077 tabindex="0"
1078 onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
1079 onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
1080 title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
1081 >
1082 {#if isPinned(item.url)}
1083 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
1084 <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
1085 </svg>
1086 {:else}
1087 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">
1088 <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
1089 </svg>
1090 {/if}
1091 </div>
1092 <div class="quick-access-visits-compact">{formatVisitLabel(item.visit_count)}</div>
1093 </div>
1094 </div>
1095 {/each}
1096 </div>
1097 {/if}
1098 </div>
1099 </section>
1100 </div>
1101
1102 <section class="search-shortcuts-panel terminal-pane full-width">
1103 <div class="panel-header">
1104 <h2 class="panel-title">Search Shortcuts</h2>
1105 <p class="panel-subtitle">Quick search prefixes for faster browsing.</p>
1106 </div>
1107 <div class="panel-body">
1108 {#if searchShortcutsLoading}
1109 <div class="loading">Loading search shortcuts...</div>
1110 {:else if Object.keys(searchShortcuts).length === 0}
1111 <div class="empty-state compact">
1112 <h3>No shortcuts configured</h3>
1113 <p>Add search shortcuts to your config file.</p>
1114 </div>
1115 {:else}
1116 <div class="shortcuts-list">
1117 {#each Object.entries(searchShortcuts) as [key, shortcut] (key)}
1118 <div class="shortcut-item">
1119 <div class="shortcut-key-badge">{key}:</div>
1120 <div class="shortcut-desc">{shortcut.description.toLowerCase()}</div>
1121 </div>
1122 {/each}
1123 </div>
1124 {/if}
1125 </div>
1126 </section>
1127
1128 <section class="shortcuts-panel terminal-pane full-width">
1129 <div class="panel-header">
1130 <h2 class="panel-title">Keyboard Shortcuts</h2>
1131 <p class="panel-subtitle">Stay quick with the essentials.</p>
1132 </div>
1133 <div class="panel-body">
1134 {#if shortcutsLoading}
1135 <div class="loading">Loading shortcuts...</div>
1136 {:else}
1137 <div class="shortcuts-list">
1138 {#each shortcuts as shortcut (shortcut.key)}
1139 <div class="shortcut-item">
1140 <div class="shortcut-key-badge">{shortcut.key}</div>
1141 <div class="shortcut-desc">{shortcut.description}</div>
1142 </div>
1143 {/each}
1144 </div>
1145 {/if}
1146 </div>
1147 </section>
1148
1149 <section class="stats-panel terminal-pane full-width">
1150 <div class="panel-header">
1151 <h2 class="panel-title">Usage Stats</h2>
1152 <p class="panel-subtitle">Your browsing activity at a glance.</p>
1153 </div>
1154 <div class="panel-body">
1155 {#if statsLoading}
1156 <div class="loading">Loading stats...</div>
1157 {:else if stats}
1158 <div class="stats-grid-horizontal">
1159 <div class="stat-item-horizontal">
1160 <div class="stat-value">{stats.total_entries}</div>
1161 <div class="stat-label">Entries Stored</div>
1162 </div>
1163 <div class="stat-item-horizontal">
1164 <div class="stat-value">{stats.recent_count}</div>
1165 <div class="stat-label">Recent Window</div>
1166 </div>
1167 {#if stats.newest_entry}
1168 <div class="stat-item-horizontal">
1169 <div class="stat-value">{formatTime(stats.newest_entry)}</div>
1170 <div class="stat-label">Newest Visit</div>
1171 </div>
1172 {/if}
1173 {#if stats.oldest_entry}
1174 <div class="stat-item-horizontal">
1175 <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
1176 <div class="stat-label">Oldest Entry</div>
1177 </div>
1178 {/if}
1179 </div>
1180 {:else}
1181 <div class="empty-state compact">
1182 <p>No statistics available</p>
1183 </div>
1184 {/if}
1185 </div>
1186 </section>
1187 </div>
1188 </div>
1189
1190 <Footer />
1191</div>
1192
1193<style>
1194.homepage-shell {
1195 min-height: 100vh;
1196 background-color: var(--dynamic-bg);
1197 color: var(--dynamic-text);
1198 display: flex;
1199 flex-direction: column;
1200 padding: clamp(1.5rem, 4vw, 2.5rem);
1201 font-family:
1202 "JetBrains Mono",
1203 "Fira Code",
1204 "SFMono-Regular",
1205 Menlo,
1206 monospace;
1207 line-height: 1.45;
1208}
1209
1210.homepage-shell * {
1211 box-sizing: border-box;
1212}
1213
1214.terminal-frame {
1215 flex: 1;
1216 width: 100%;
1217 max-width: 1160px;
1218 margin: 0 auto;
1219 display: flex;
1220 flex-direction: column;
1221 border: 1px solid var(--dynamic-border);
1222 background: color-mix(in srgb, var(--dynamic-surface) 80%, var(--dynamic-bg) 20%);
1223 box-shadow:
1224 inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 30%, transparent),
1225 0 16px 28px -24px rgb(0 0 0 / 0.6);
1226}
1227
1228.terminal-header {
1229 display: flex;
1230 align-items: center;
1231 justify-content: space-between;
1232 gap: 1rem;
1233 padding: 0.85rem 1.1rem;
1234 border-bottom: 1px solid var(--dynamic-border);
1235 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1236 text-transform: uppercase;
1237 letter-spacing: 0.08em;
1238 font-size: 0.78rem;
1239}
1240
1241.terminal-heading {
1242 display: flex;
1243 flex-direction: column;
1244 gap: 0.35rem;
1245}
1246
1247.terminal-path {
1248 font-weight: 600;
1249 color: var(--dynamic-text);
1250}
1251
1252.terminal-meta {
1253 color: var(--dynamic-muted);
1254 font-size: 0.72rem;
1255 letter-spacing: 0.12em;
1256}
1257
1258.theme-toggle-button {
1259 width: 2.5rem;
1260 height: 2.5rem;
1261 display: inline-flex;
1262 align-items: center;
1263 justify-content: center;
1264 border: 1px solid var(--dynamic-border);
1265 background-color: var(--dynamic-bg);
1266 color: var(--dynamic-muted);
1267 transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease;
1268 border-radius: 0;
1269 cursor: pointer;
1270}
1271
1272.theme-toggle-button:hover,
1273.theme-toggle-button:focus-visible {
1274 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1275 color: var(--dynamic-text);
1276 background-color: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1277 outline: none;
1278}
1279
1280.theme-toggle-button svg {
1281 width: 1.3rem;
1282 height: 1.3rem;
1283}
1284
1285.sr-only {
1286 position: absolute;
1287 width: 1px;
1288 height: 1px;
1289 padding: 0;
1290 margin: -1px;
1291 overflow: hidden;
1292 clip: rect(0, 0, 0, 0);
1293 white-space: nowrap;
1294 border: 0;
1295}
1296
1297.terminal-body {
1298 display: flex;
1299 flex-direction: column;
1300 gap: 1.25rem;
1301 padding: 1.25rem 1.5rem 1.5rem;
1302 background: var(--dynamic-bg);
1303}
1304
1305.terminal-status-row {
1306 display: flex;
1307 flex-wrap: wrap;
1308 gap: 0.75rem;
1309}
1310
1311.status-chip {
1312 display: inline-flex;
1313 align-items: center;
1314 gap: 0.5rem;
1315 padding: 0.45rem 0.8rem;
1316 border: 1px solid var(--dynamic-border);
1317 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1318 font-size: 0.68rem;
1319 letter-spacing: 0.1em;
1320 text-transform: uppercase;
1321 color: var(--dynamic-muted);
1322}
1323
1324.status-chip .chip-value {
1325 color: var(--dynamic-text);
1326 font-weight: 600;
1327}
1328
1329.last-visit-banner {
1330 margin-top: 0.9rem;
1331 display: grid;
1332 grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(120px, auto) minmax(120px, auto) minmax(120px, auto);
1333 gap: 0.75rem;
1334 align-items: center;
1335 padding: 0.75rem 1rem;
1336 border: 1px solid var(--dynamic-border);
1337 background: color-mix(in srgb, var(--dynamic-bg) 90%, var(--dynamic-surface) 10%);
1338 color: var(--dynamic-text);
1339 text-transform: uppercase;
1340 letter-spacing: 0.08em;
1341 transition: background-color 150ms ease, border-color 150ms ease;
1342 cursor: pointer;
1343}
1344
1345.last-visit-banner:hover,
1346.last-visit-banner:focus-visible {
1347 background: color-mix(in srgb, var(--dynamic-bg) 72%, var(--dynamic-surface) 28%);
1348 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1349 outline: none;
1350}
1351
1352.last-visit-banner[tabindex="-1"],
1353.last-visit-banner[tabindex="-1"]:hover {
1354 cursor: default;
1355 background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
1356}
1357
1358.banner-label {
1359 color: var(--dynamic-muted);
1360 font-size: 0.68rem;
1361}
1362
1363.banner-title {
1364 font-weight: 600;
1365 white-space: nowrap;
1366 overflow: hidden;
1367 letter-spacing: 0.06em;
1368}
1369
1370:global(.banner-title.truncated) {
1371 mask-image: linear-gradient(to right, black 85%, transparent 100%);
1372 -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1373}
1374
1375.banner-domain {
1376 color: var(--dynamic-muted);
1377 font-size: 0.68rem;
1378 letter-spacing: 0.1em;
1379}
1380
1381.banner-time {
1382 font-size: 0.72rem;
1383 color: var(--dynamic-text);
1384 white-space: nowrap;
1385}
1386
1387.banner-absolute {
1388 font-size: 0.68rem;
1389 color: var(--dynamic-muted);
1390 white-space: nowrap;
1391}
1392
1393.banner-empty {
1394 grid-column: 2 / -1;
1395 color: var(--dynamic-muted);
1396 font-size: 0.72rem;
1397 letter-spacing: 0.1em;
1398}
1399
1400.terminal-grid {
1401 display: grid;
1402 gap: 1rem;
1403}
1404
1405.main-panels {
1406 grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
1407}
1408
1409.terminal-pane {
1410 border: 1px solid var(--dynamic-border);
1411 background: color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%);
1412 display: flex;
1413 flex-direction: column;
1414 min-height: 0;
1415 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 18%, transparent);
1416}
1417
1418.terminal-pane.full-width {
1419 width: 100%;
1420 grid-column: 1 / -1;
1421}
1422
1423.panel-header {
1424 display: flex;
1425 flex-direction: column;
1426 gap: 0.3rem;
1427 padding: 1rem 1.1rem;
1428 border-bottom: 1px solid var(--dynamic-border);
1429 background: color-mix(in srgb, var(--dynamic-bg) 75%, transparent);
1430}
1431
1432.panel-title {
1433 margin: 0;
1434 font-size: 0.95rem;
1435 text-transform: uppercase;
1436 letter-spacing: 0.08em;
1437 color: var(--dynamic-text);
1438}
1439
1440.panel-subtitle {
1441 margin: 0;
1442 font-size: 0.72rem;
1443 color: var(--dynamic-muted);
1444 letter-spacing: 0.1em;
1445 text-transform: uppercase;
1446}
1447
1448.panel-body {
1449 padding: 1.1rem 1.1rem 1.25rem;
1450 display: flex;
1451 flex-direction: column;
1452 gap: 1.1rem;
1453 min-height: 0;
1454 overflow: hidden;
1455}
1456
1457.loading {
1458 padding: 0.9rem;
1459 text-align: center;
1460 color: var(--dynamic-muted);
1461 border: 1px dashed var(--dynamic-border);
1462 background-color: color-mix(in srgb, var(--dynamic-bg) 85%, var(--dynamic-surface) 15%);
1463 text-transform: uppercase;
1464 letter-spacing: 0.08em;
1465 font-size: 0.7rem;
1466}
1467
1468.empty-state {
1469 border: 1px dashed var(--dynamic-border);
1470 padding: 1.4rem;
1471 display: flex;
1472 flex-direction: column;
1473 gap: 0.5rem;
1474 align-items: center;
1475 text-align: center;
1476 color: var(--dynamic-muted);
1477 background-color: color-mix(in srgb, var(--dynamic-bg) 90%, var(--dynamic-surface) 10%);
1478 letter-spacing: 0.06em;
1479}
1480
1481.empty-state h3 {
1482 margin: 0;
1483 color: var(--dynamic-text);
1484 font-size: 0.9rem;
1485 text-transform: uppercase;
1486}
1487
1488.empty-state p {
1489 margin: 0;
1490 font-size: 0.72rem;
1491}
1492
1493.empty-state.compact {
1494 padding: 1rem;
1495}
1496
1497.history-body {
1498 gap: 0.75rem;
1499 display: flex;
1500 flex-direction: column;
1501 min-height: 0;
1502 overflow: hidden;
1503}
1504
1505.history-list {
1506 display: flex;
1507 flex-direction: column;
1508 flex: 1;
1509 min-height: 0;
1510 overflow-y: auto;
1511 border: 1px solid var(--dynamic-border);
1512 background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
1513}
1514
1515.history-item {
1516 display: grid;
1517 grid-template-columns: auto 1fr auto;
1518 align-items: center;
1519 gap: 0.75rem;
1520 padding: 0.6rem 0.9rem;
1521 border-bottom: 1px dashed var(--dynamic-border);
1522 transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
1523}
1524
1525.history-item:last-child {
1526 border-bottom: none;
1527}
1528
1529.history-item:hover,
1530.history-item:focus-visible {
1531 background: color-mix(in srgb, var(--dynamic-bg) 65%, var(--dynamic-surface) 35%);
1532 outline: none;
1533}
1534
1535.history-item.deleting {
1536 opacity: 0.35;
1537 pointer-events: none;
1538 transform: translateX(4px);
1539}
1540
1541.history-item-leading {
1542 width: 38px;
1543 height: 38px;
1544 border: 1px solid var(--dynamic-border);
1545 background-color: var(--dynamic-bg);
1546 display: flex;
1547 align-items: center;
1548 justify-content: center;
1549 flex-shrink: 0;
1550}
1551
1552.history-favicon-img {
1553 width: 60%;
1554 height: 60%;
1555 object-fit: contain;
1556}
1557
1558.history-favicon-fallback {
1559 display: flex;
1560 align-items: center;
1561 justify-content: center;
1562 width: 100%;
1563 height: 100%;
1564 color: var(--dynamic-muted);
1565}
1566
1567.history-favicon-fallback svg {
1568 opacity: 0.55;
1569}
1570
1571.history-item-content {
1572 display: flex;
1573 flex-direction: column;
1574 gap: 0.2rem;
1575 min-width: 0;
1576}
1577
1578.history-item-main {
1579 display: flex;
1580 align-items: center;
1581 justify-content: space-between;
1582 gap: 0.75rem;
1583}
1584
1585.history-title {
1586 font-weight: 600;
1587 color: var(--dynamic-text);
1588 white-space: nowrap;
1589 overflow: hidden;
1590}
1591
1592:global(.history-title.truncated) {
1593 mask-image: linear-gradient(to right, black 85%, transparent 100%);
1594 -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1595}
1596
1597.history-title:hover {
1598 cursor: pointer;
1599}
1600
1601.history-time {
1602 font-size: 0.72rem;
1603 color: var(--dynamic-muted);
1604 text-transform: uppercase;
1605 letter-spacing: 0.08em;
1606 white-space: nowrap;
1607}
1608
1609.history-url {
1610 font-size: 0.72rem;
1611 color: var(--dynamic-muted);
1612 white-space: nowrap;
1613 overflow: hidden;
1614}
1615
1616:global(.history-url.truncated) {
1617 mask-image: linear-gradient(to right, black 85%, transparent 100%);
1618 -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1619}
1620
1621.history-url :global(.history-url-domain) {
1622 color: var(--dynamic-text) !important;
1623 font-weight: 600;
1624}
1625
1626.history-actions {
1627 display: flex;
1628 gap: 0.35rem;
1629}
1630
1631.history-pin,
1632.history-delete {
1633 display: inline-flex;
1634 align-items: center;
1635 justify-content: center;
1636 width: 28px;
1637 height: 28px;
1638 border: 1px solid var(--dynamic-border);
1639 color: var(--dynamic-muted);
1640 background: transparent;
1641 transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
1642}
1643
1644.history-pin:hover,
1645.history-pin:focus-visible,
1646.history-delete:hover,
1647.history-delete:focus-visible {
1648 color: var(--dynamic-text);
1649 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1650 background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1651 outline: none;
1652}
1653
1654.scroll-sentinel {
1655 height: 1px;
1656}
1657
1658.loading-more {
1659 padding: 0.75rem;
1660 font-size: 0.7rem;
1661 text-align: center;
1662 color: var(--dynamic-muted);
1663 letter-spacing: 0.08em;
1664 text-transform: uppercase;
1665}
1666
1667.quick-access-body {
1668 gap: 0.9rem;
1669}
1670
1671.quick-access-grid-compact {
1672 display: flex;
1673 flex-direction: column;
1674 gap: 0.75rem;
1675}
1676
1677.quick-access-item-compact {
1678 border: 1px solid var(--dynamic-border);
1679 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1680 padding: 0.75rem 0.9rem;
1681 display: grid;
1682 grid-template-columns: auto 1fr auto;
1683 gap: 0.75rem;
1684 align-items: center;
1685 transition: background-color 150ms ease, border-color 150ms ease;
1686 cursor: pointer;
1687}
1688
1689.quick-access-item-compact:hover,
1690.quick-access-item-compact:focus-visible {
1691 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1692 background: color-mix(in srgb, var(--dynamic-bg) 70%, var(--dynamic-surface) 30%);
1693 outline: none;
1694}
1695
1696.quick-access-favicon-compact {
1697 width: 36px;
1698 height: 36px;
1699 border: 1px solid var(--dynamic-border);
1700 display: flex;
1701 align-items: center;
1702 justify-content: center;
1703 background: var(--dynamic-bg);
1704}
1705
1706.quick-access-favicon-img-compact {
1707 width: 60%;
1708 height: 60%;
1709 object-fit: contain;
1710}
1711
1712.quick-access-fallback-compact {
1713 display: flex;
1714 align-items: center;
1715 justify-content: center;
1716 width: 100%;
1717 height: 100%;
1718 color: var(--dynamic-muted);
1719}
1720
1721.quick-access-fallback-compact svg {
1722 opacity: 0.55;
1723}
1724
1725.quick-access-meta-compact {
1726 display: flex;
1727 flex-direction: column;
1728 gap: 0.2rem;
1729 min-width: 0;
1730}
1731
1732.quick-access-title-compact {
1733 color: var(--dynamic-text);
1734 font-weight: 600;
1735 white-space: nowrap;
1736 overflow: hidden;
1737}
1738
1739:global(.quick-access-title-compact.truncated) {
1740 mask-image: linear-gradient(to right, black 85%, transparent 100%);
1741 -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1742}
1743
1744.quick-access-title-compact:hover {
1745 cursor: pointer;
1746}
1747
1748.quick-access-domain-compact {
1749 color: var(--dynamic-muted);
1750 font-size: 0.72rem;
1751 text-transform: uppercase;
1752 letter-spacing: 0.08em;
1753}
1754
1755.quick-access-actions-compact {
1756 display: flex;
1757 flex-direction: column;
1758 gap: 0.3rem;
1759 align-items: flex-end;
1760 justify-content: center;
1761}
1762
1763.quick-access-pin-compact {
1764 display: inline-flex;
1765 align-items: center;
1766 justify-content: center;
1767 width: 26px;
1768 height: 26px;
1769 border: 1px solid var(--dynamic-border);
1770 color: var(--dynamic-muted);
1771 background: transparent;
1772 transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
1773 cursor: pointer;
1774}
1775
1776.quick-access-pin-compact:hover,
1777.quick-access-pin-compact:focus-visible {
1778 color: var(--dynamic-text);
1779 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1780 background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1781 outline: none;
1782}
1783
1784.quick-access-pin-compact svg {
1785 width: 14px;
1786 height: 14px;
1787}
1788
1789.quick-access-visits-compact {
1790 font-size: 0.7rem;
1791 color: var(--dynamic-muted);
1792 text-transform: uppercase;
1793 letter-spacing: 0.08em;
1794}
1795
1796.show-more-button {
1797 width: 100%;
1798 padding: 0.65rem 0.75rem;
1799 border: 1px solid var(--dynamic-border);
1800 background: transparent;
1801 color: var(--dynamic-muted);
1802 font-size: 0.72rem;
1803 text-transform: uppercase;
1804 letter-spacing: 0.08em;
1805 cursor: pointer;
1806 transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease;
1807 border-radius: 0;
1808}
1809
1810.show-more-button:hover,
1811.show-more-button:focus-visible {
1812 background: color-mix(in srgb, var(--dynamic-bg) 75%, var(--dynamic-surface) 25%);
1813 color: var(--dynamic-text);
1814 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1815 outline: none;
1816}
1817
1818.stats-grid-horizontal {
1819 display: grid;
1820 gap: 0.85rem;
1821 grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1822}
1823
1824.stat-item-horizontal {
1825 border: 1px solid var(--dynamic-border);
1826 padding: 0.85rem;
1827 display: flex;
1828 flex-direction: column;
1829 gap: 0.4rem;
1830 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1831 text-transform: uppercase;
1832 letter-spacing: 0.08em;
1833}
1834
1835.stat-value {
1836 font-size: 0.95rem;
1837 color: var(--dynamic-text);
1838 font-weight: 600;
1839}
1840
1841.stat-label {
1842 font-size: 0.68rem;
1843 color: var(--dynamic-muted);
1844}
1845
1846.shortcuts-list {
1847 display: grid;
1848 grid-template-columns: repeat(2, 1fr);
1849 gap: 0.6rem;
1850}
1851
1852.shortcut-item {
1853 display: grid;
1854 grid-template-columns: auto 1fr;
1855 gap: 0.6rem;
1856 align-items: center;
1857 border: 1px solid var(--dynamic-border);
1858 padding: 0.6rem 0.75rem;
1859 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1860}
1861
1862.shortcut-key-badge {
1863 font-size: 0.72rem;
1864 text-transform: uppercase;
1865 letter-spacing: 0.08em;
1866 padding: 0.25rem 0.6rem;
1867 border: 1px solid var(--dynamic-border);
1868 color: var(--dynamic-text);
1869 background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1870}
1871
1872.shortcut-desc {
1873 font-size: 0.72rem;
1874 color: var(--dynamic-muted);
1875 letter-spacing: 0.06em;
1876}
1877
1878@media (max-width: 960px) {
1879 .terminal-body {
1880 padding: 1.1rem;
1881 }
1882
1883 .terminal-grid {
1884 gap: 0.75rem;
1885 }
1886
1887 .main-panels {
1888 grid-template-columns: 1fr;
1889 }
1890
1891 .last-visit-banner {
1892 grid-template-columns: minmax(0, auto) minmax(0, 1fr);
1893 grid-template-rows: repeat(3, auto);
1894 gap: 0.5rem 0.75rem;
1895 }
1896
1897 .banner-time,
1898 .banner-absolute {
1899 justify-self: flex-start;
1900 }
1901}
1902
1903@media (max-width: 640px) {
1904 .homepage-shell {
1905 padding: 1.25rem;
1906 }
1907
1908 .terminal-header {
1909 flex-direction: column;
1910 align-items: flex-start;
1911 }
1912
1913 .theme-toggle-button {
1914 align-self: flex-end;
1915 }
1916
1917 .terminal-status-row {
1918 gap: 0.5rem;
1919 }
1920
1921 .status-chip {
1922 padding: 0.35rem 0.65rem;
1923 }
1924
1925 .last-visit-banner {
1926 grid-template-columns: minmax(0, 1fr);
1927 grid-template-rows: repeat(4, auto);
1928 }
1929
1930 .banner-label,
1931 .banner-title,
1932 .banner-domain,
1933 .banner-time,
1934 .banner-absolute {
1935 justify-self: flex-start;
1936 }
1937
1938 .shortcuts-list {
1939 grid-template-columns: 1fr;
1940 }
1941}
1942</style>