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 switch (mode) {
553 case "light":
554 document.documentElement.classList.add("light");
555 document.documentElement.classList.remove("dark");
556 break;
557 case "dark":
558 document.documentElement.classList.remove("light");
559 document.documentElement.classList.add("dark");
560 break;
561 }
562 persistTheme(mode);
563 };
564
565 const toggleTheme = () => {
566 const nextMode = themeMode === "dark" ? "light" : "dark";
567 applyTheme(nextMode);
568 };
569
570 // Fetch history stats via message bridge
571 const fetchStats = async () => {
572 try {
573 // Set up response handler
574 (window as any).__dumber_history_stats = (data: any) => {
575 stats = data;
576 statsLoading = false;
577 };
578
579 (window as any).__dumber_history_error = (error: string) => {
580 console.error('Error fetching stats:', error);
581 stats = null;
582 statsLoading = false;
583 };
584
585 // Send message to Go backend
586 const bridge = (window as any).webkit?.messageHandlers?.dumber;
587 if (bridge && typeof bridge.postMessage === 'function') {
588 bridge.postMessage(JSON.stringify({
589 type: 'history_stats'
590 }));
591 } else {
592 throw new Error('WebKit message handler not available');
593 }
594 } catch (error) {
595 console.error('Error sending stats request:', error);
596 stats = null;
597 statsLoading = false;
598 }
599 };
600
601 // Fetch top visited sites via message bridge
602 const fetchTopVisited = async () => {
603 try {
604 // Set up response handler
605 (window as any).__dumber_history_search = (data: any[]) => {
606 // Sort by visit_count in descending order and take top 5
607 const sortedData = Array.isArray(data) ?
608 data.sort((a, b) => (b.visit_count || 0) - (a.visit_count || 0)).slice(0, 5) : [];
609 topVisited = sortedData;
610 topVisitedLoading = false;
611 };
612
613 (window as any).__dumber_history_error = (error: string) => {
614 console.error('Error fetching top visited:', error);
615 topVisited = [];
616 topVisitedLoading = false;
617 };
618
619 // Send message to Go backend
620 const bridge = (window as any).webkit?.messageHandlers?.dumber;
621 if (bridge && typeof bridge.postMessage === 'function') {
622 bridge.postMessage(JSON.stringify({
623 type: 'history_search',
624 q: '',
625 limit: 50 // Get more results to sort properly
626 }));
627 } else {
628 throw new Error('WebKit message handler not available');
629 }
630 } catch (error) {
631 console.error('Error sending top visited request:', error);
632 topVisited = [];
633 topVisitedLoading = false;
634 }
635 };
636
637 // Fetch search shortcuts via message bridge
638 const fetchSearchShortcuts = async () => {
639 try {
640 // Set up response handler
641 (window as any).__dumber_search_shortcuts = (data: unknown) => {
642 try {
643 if (typeof data !== 'object' || data === null) {
644 throw new Error('Invalid shortcuts data');
645 }
646 const dataObj = data as Record<string, unknown>;
647 const normalized: Record<string, SearchShortcut> = {};
648 for (const [key, value] of Object.entries(dataObj)) {
649 const v = value as Record<string, unknown>;
650 normalized[key] = {
651 url: (v.url ?? v.URL ?? '') as string,
652 description: (v.description ?? v.Description ?? '') as string,
653 };
654 }
655 searchShortcuts = normalized;
656 searchShortcutsLoading = false;
657 } catch (error) {
658 console.error('Failed to process search shortcuts:', error);
659 searchShortcuts = {};
660 searchShortcutsLoading = false;
661 }
662 };
663
664 // Send message to Go backend
665 const bridge = (window as any).webkit?.messageHandlers?.dumber;
666 if (bridge && typeof bridge.postMessage === 'function') {
667 bridge.postMessage(JSON.stringify({
668 type: 'get_search_shortcuts'
669 }));
670 } else {
671 throw new Error('WebKit message handler not available');
672 }
673 } catch (error) {
674 console.error('Error fetching search shortcuts:', error);
675 searchShortcuts = {};
676 searchShortcutsLoading = false;
677 }
678 };
679
680 // Intersection Observer for infinite scroll
681 let historyScrollSentinel = $state<HTMLElement | null>(null);
682
683 const setupInfiniteScroll = () => {
684 if (!historyScrollSentinel) return;
685
686 const observer = new IntersectionObserver(
687 (entries) => {
688 const entry = entries[0];
689 if (entry && entry.isIntersecting && hasMoreHistory && !loadingMoreHistory) {
690 loadMoreHistory();
691 }
692 },
693 {
694 threshold: 0.1,
695 rootMargin: '50px'
696 }
697 );
698
699 observer.observe(historyScrollSentinel);
700
701 return () => observer.disconnect();
702 };
703
704 // Helper to check if text overflows and apply truncation class
705 const checkOverflow = (node: HTMLElement) => {
706 const check = () => {
707 // Add 1px tolerance for sub-pixel rendering differences
708 // This prevents false positives from rounding errors
709 const isOverflowing = node.scrollWidth > (node.clientWidth + 1);
710
711 if (isOverflowing) {
712 node.classList.add('truncated');
713 } else {
714 node.classList.remove('truncated');
715 }
716 };
717
718 // Delay initial check to ensure layout is complete
719 requestAnimationFrame(() => {
720 requestAnimationFrame(check);
721 });
722
723 const resizeObserver = new ResizeObserver(() => {
724 requestAnimationFrame(check);
725 });
726
727 resizeObserver.observe(node);
728
729 return {
730 destroy() {
731 resizeObserver.disconnect();
732 }
733 };
734 };
735
736 onMount(() => {
737 loadPinnedSites();
738 initializeShortcuts();
739 fetchHistory().then(() => {
740 // Setup infinite scroll after initial history is loaded
741 setTimeout(setupInfiniteScroll, 100);
742 });
743 fetchStats();
744 fetchTopVisited();
745 fetchSearchShortcuts();
746
747 // Theme synchronization
748 const syncThemeState = () => {
749 themeMode = document.documentElement.classList.contains("dark")
750 ? "dark"
751 : "light";
752 };
753 syncThemeState();
754 themeObserver = new MutationObserver(syncThemeState);
755 themeObserver.observe(document.documentElement, {
756 attributes: true,
757 attributeFilter: ["class"],
758 });
759
760 return () => {
761 themeObserver?.disconnect();
762 themeObserver = null;
763 };
764 });
765
766
767</script>
768
769<svelte:head>
770 <title>Dumber Browser - Homepage</title>
771 <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" />
772
773 {@html `<style>
774 html { background: var(--dynamic-bg, #0a0a0a); }
775 body { background: var(--dynamic-bg, #0a0a0a); color: var(--dynamic-text, #e5e5e5); }
776 </style>`}
777
778 {@html `<script>
779 (function() {
780 const theme = localStorage.getItem('dumber.theme');
781 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
782 const shouldBeLight = theme === 'light' || (theme !== 'dark' && !prefersDark);
783 if (shouldBeLight) {
784 document.documentElement.classList.add('light');
785 }
786 })();
787 </script>`}
788</svelte:head>
789
790<div class="homepage-shell">
791 <div class="terminal-frame">
792 <header class="terminal-header">
793 <div class="terminal-heading">
794 <span class="terminal-path">dumb://home</span>
795 <span class="terminal-meta">
796 {statsLoading ? 'syncing…' : `${stats?.recent_count ?? 0} recent`}
797 · {historyItems.length} entries
798 · {pinnedSites.size} pinned
799 </span>
800 </div>
801 <button
802 class="theme-toggle-button"
803 type="button"
804 aria-pressed={themeMode === "dark"}
805 title={themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
806 onclick={toggleTheme}
807 >
808 <span class="sr-only">
809 {themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
810 </span>
811 {#if themeMode === "dark"}
812 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
813 <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
814 </svg>
815 {:else}
816 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
817 <circle cx="12" cy="12" r="5" fill="currentColor" />
818 <g stroke="currentColor" stroke-width="2" stroke-linecap="round">
819 <line x1="12" y1="1" x2="12" y2="4" />
820 <line x1="12" y1="20" x2="12" y2="23" />
821 <line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
822 <line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
823 <line x1="1" y1="12" x2="4" y2="12" />
824 <line x1="20" y1="12" x2="23" y2="12" />
825 <line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
826 <line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
827 </g>
828 </svg>
829 {/if}
830 </button>
831 </header>
832
833 <div class="terminal-body">
834 <div class="terminal-status-row">
835 <div class="status-chip">
836 <span class="chip-label">history</span>
837 <span class="chip-value">{historyItems.length}</span>
838 </div>
839 <div class="status-chip">
840 <span class="chip-label">pinned</span>
841 <span class="chip-value">{pinnedSites.size}</span>
842 </div>
843 <div class="status-chip">
844 <span class="chip-label">quick-links</span>
845 <span class="chip-value">{quickAccess.length}</span>
846 </div>
847 {#if stats && stats.total_entries}
848 <div class="status-chip">
849 <span class="chip-label">stored</span>
850 <span class="chip-value">{stats.total_entries}</span>
851 </div>
852 {/if}
853 </div>
854
855 <div
856 class="last-visit-banner"
857 role="button"
858 tabindex={latestVisitInfo ? 0 : -1}
859 onclick={() => latestVisitInfo && navigateTo(latestVisitInfo.entry.url)}
860 onkeydown={(event) => {
861 if (event.key === 'Enter' && latestVisitInfo) {
862 navigateTo(latestVisitInfo.entry.url);
863 }
864 }}
865 aria-label={latestVisitInfo ? `Open ${latestVisitInfo.title}` : 'No recorded visits'}
866 >
867 <span class="banner-label">last-visit</span>
868 {#if latestVisitInfo}
869 <span class="banner-title" use:checkOverflow>
870 {latestVisitInfo.title}
871 </span>
872 <span class="banner-domain">{latestVisitInfo.domain}</span>
873 <span class="banner-time">{latestVisitInfo.relative}</span>
874 <span class="banner-absolute">{latestVisitInfo.absolute}</span>
875 {:else}
876 <span class="banner-empty">none recorded yet</span>
877 {/if}
878 </div>
879
880 <div class="terminal-grid main-panels">
881 <section class="history-panel terminal-pane">
882 <div class="panel-header">
883 <h2 class="panel-title">Recent History</h2>
884 <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>
885 </div>
886 <div class="panel-body history-body">
887 {#if historyLoading}
888 <div class="loading">Loading history...</div>
889 {:else if historyItems.length === 0}
890 <div class="empty-state">
891 <h3>No history yet</h3>
892 <p>Start browsing to see your recent history here.</p>
893 </div>
894 {:else}
895 <div class="history-list">
896 {#each displayedHistoryItems as item (item.id)}
897 <div
898 class="history-item"
899 class:deleting={isDeleting(item.id)}
900 role="button"
901 tabindex="0"
902 onclick={() => navigateTo(item.url)}
903 onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
904 >
905 <div class="history-item-leading">
906 {#if item.favicon_url}
907 <img
908 src={item.favicon_url}
909 alt=""
910 class="history-favicon-img"
911 onerror={(e) => {
912 const target = e.target as HTMLImageElement;
913 if (target) {
914 target.style.display = 'none';
915 const fallback = target.nextElementSibling as HTMLElement;
916 if (fallback) fallback.style.display = 'flex';
917 }
918 }}
919 onload={(e) => {
920 const target = e.target as HTMLImageElement;
921 if (target) {
922 const fallback = target.nextElementSibling as HTMLElement;
923 if (fallback) fallback.style.display = 'none';
924 }
925 }}
926 />
927 <div class="history-favicon-fallback" style="display: none;">
928 <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">
929 <circle cx="12" cy="12" r="10" />
930 <path d="M2 12h20" />
931 </svg>
932 </div>
933 {:else}
934 <div class="history-favicon-fallback">
935 <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">
936 <circle cx="12" cy="12" r="10" />
937 <path d="M2 12h20" />
938 </svg>
939 </div>
940 {/if}
941 </div>
942 <div class="history-item-content">
943 <div class="history-item-main">
944 <div class="history-title" use:checkOverflow>
945 {getDisplayTitle(item)}
946 </div>
947 <div class="history-time">{formatTime(item.last_visited)}</div>
948 </div>
949 <div class="history-url" use:checkOverflow>
950 {@html highlightDomainInUrl(item.url)}
951 </div>
952 </div>
953 <div class="history-actions">
954 <div
955 class="history-pin"
956 role="button"
957 tabindex="0"
958 onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
959 onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
960 title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
961 >
962 {#if isPinned(item.url)}
963 <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
964 <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"/>
965 </svg>
966 {:else}
967 <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">
968 <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"/>
969 </svg>
970 {/if}
971 </div>
972 <div
973 class="history-delete"
974 role="button"
975 tabindex="0"
976 onclick={(e) => deleteHistoryEntry(item.id, e)}
977 onkeydown={(e) => e.key === 'Enter' && deleteHistoryEntry(item.id, e)}
978 title="Delete this entry"
979 >
980 <span aria-hidden="true">×</span>
981 </div>
982 </div>
983 </div>
984 {/each}
985
986 {#if !showAllHistory && historyItems.length > INITIAL_HISTORY_DISPLAY}
987 <button
988 class="show-more-button"
989 onclick={() => showAllHistory = true}
990 type="button"
991 >
992 Show {historyItems.length - INITIAL_HISTORY_DISPLAY} more items
993 </button>
994 {:else if showAllHistory && hasMoreHistory}
995 <div bind:this={historyScrollSentinel} class="scroll-sentinel"></div>
996 {#if loadingMoreHistory}
997 <div class="loading-more">Loading more history...</div>
998 {/if}
999 {/if}
1000 </div>
1001 {/if}
1002 </div>
1003 </section>
1004
1005 <section class="quick-access-panel terminal-pane">
1006 <div class="panel-header">
1007 <h2 class="panel-title">Jump back in</h2>
1008 <p class="panel-subtitle">Direct access to the sites you like the most.</p>
1009 </div>
1010 <div class="panel-body quick-access-body">
1011 {#if quickAccessLoading}
1012 <div class="loading">Preparing your shortcuts...</div>
1013 {:else if quickAccess.length === 0}
1014 <div class="empty-state compact">
1015 <h3>No quick links yet</h3>
1016 <p>Browse a few sites and we'll surface your frequent destinations here.</p>
1017 </div>
1018 {:else}
1019 <div class="quick-access-grid-compact">
1020 {#each quickAccess as item (item.id)}
1021 <div
1022 class="quick-access-item-compact"
1023 role="button"
1024 tabindex="0"
1025 onclick={() => navigateTo(item.url)}
1026 onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
1027 >
1028 <div class="quick-access-favicon-compact">
1029 {#if item.favicon_url}
1030 <img
1031 src={item.favicon_url}
1032 alt=""
1033 class="quick-access-favicon-img-compact"
1034 onerror={(e) => {
1035 const target = e.target as HTMLImageElement;
1036 if (target) {
1037 target.style.display = 'none';
1038 const fallback = target.nextElementSibling as HTMLElement;
1039 if (fallback) fallback.style.display = 'flex';
1040 }
1041 }}
1042 onload={(e) => {
1043 const target = e.target as HTMLImageElement;
1044 if (target) {
1045 const fallback = target.nextElementSibling as HTMLElement;
1046 if (fallback) fallback.style.display = 'none';
1047 }
1048 }}
1049 />
1050 <div class="quick-access-fallback-compact" style="display: none;">
1051 <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">
1052 <circle cx="12" cy="12" r="10" />
1053 <path d="M2 12h20" />
1054 </svg>
1055 </div>
1056 {:else}
1057 <div class="quick-access-fallback-compact">
1058 <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">
1059 <circle cx="12" cy="12" r="10" />
1060 <path d="M2 12h20" />
1061 </svg>
1062 </div>
1063 {/if}
1064 </div>
1065 <div class="quick-access-meta-compact">
1066 <div class="quick-access-title-compact" use:checkOverflow>
1067 {getDisplayTitle(item)}
1068 </div>
1069 <div class="quick-access-domain-compact">{getDomain(item.url)}</div>
1070 </div>
1071 <div class="quick-access-actions-compact">
1072 <div
1073 class="quick-access-pin-compact"
1074 role="button"
1075 tabindex="0"
1076 onclick={(e) => { e.stopPropagation(); togglePin(item.url); }}
1077 onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); togglePin(item.url); } }}
1078 title={isPinned(item.url) ? "Unpin from favorites" : "Pin to favorites"}
1079 >
1080 {#if isPinned(item.url)}
1081 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
1082 <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"/>
1083 </svg>
1084 {:else}
1085 <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">
1086 <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"/>
1087 </svg>
1088 {/if}
1089 </div>
1090 <div class="quick-access-visits-compact">{formatVisitLabel(item.visit_count)}</div>
1091 </div>
1092 </div>
1093 {/each}
1094 </div>
1095 {/if}
1096 </div>
1097 </section>
1098 </div>
1099
1100 <section class="search-shortcuts-panel terminal-pane full-width">
1101 <div class="panel-header">
1102 <h2 class="panel-title">Search Shortcuts</h2>
1103 <p class="panel-subtitle">Quick search prefixes for faster browsing.</p>
1104 </div>
1105 <div class="panel-body">
1106 {#if searchShortcutsLoading}
1107 <div class="loading">Loading search shortcuts...</div>
1108 {:else if Object.keys(searchShortcuts).length === 0}
1109 <div class="empty-state compact">
1110 <h3>No shortcuts configured</h3>
1111 <p>Add search shortcuts to your config file.</p>
1112 </div>
1113 {:else}
1114 <div class="shortcuts-list">
1115 {#each Object.entries(searchShortcuts) as [key, shortcut] (key)}
1116 <div class="shortcut-item">
1117 <div class="shortcut-key-badge">{key}:</div>
1118 <div class="shortcut-desc">{shortcut.description.toLowerCase()}</div>
1119 </div>
1120 {/each}
1121 </div>
1122 {/if}
1123 </div>
1124 </section>
1125
1126 <section class="shortcuts-panel terminal-pane full-width">
1127 <div class="panel-header">
1128 <h2 class="panel-title">Keyboard Shortcuts</h2>
1129 <p class="panel-subtitle">Stay quick with the essentials.</p>
1130 </div>
1131 <div class="panel-body">
1132 {#if shortcutsLoading}
1133 <div class="loading">Loading shortcuts...</div>
1134 {:else}
1135 <div class="shortcuts-list">
1136 {#each shortcuts as shortcut (shortcut.key)}
1137 <div class="shortcut-item">
1138 <div class="shortcut-key-badge">{shortcut.key}</div>
1139 <div class="shortcut-desc">{shortcut.description}</div>
1140 </div>
1141 {/each}
1142 </div>
1143 {/if}
1144 </div>
1145 </section>
1146
1147 <section class="stats-panel terminal-pane full-width">
1148 <div class="panel-header">
1149 <h2 class="panel-title">Usage Stats</h2>
1150 <p class="panel-subtitle">Your browsing activity at a glance.</p>
1151 </div>
1152 <div class="panel-body">
1153 {#if statsLoading}
1154 <div class="loading">Loading stats...</div>
1155 {:else if stats}
1156 <div class="stats-grid-horizontal">
1157 <div class="stat-item-horizontal">
1158 <div class="stat-value">{stats.total_entries}</div>
1159 <div class="stat-label">Entries Stored</div>
1160 </div>
1161 <div class="stat-item-horizontal">
1162 <div class="stat-value">{stats.recent_count}</div>
1163 <div class="stat-label">Recent Window</div>
1164 </div>
1165 {#if stats.newest_entry}
1166 <div class="stat-item-horizontal">
1167 <div class="stat-value">{formatTime(stats.newest_entry)}</div>
1168 <div class="stat-label">Newest Visit</div>
1169 </div>
1170 {/if}
1171 {#if stats.oldest_entry}
1172 <div class="stat-item-horizontal">
1173 <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
1174 <div class="stat-label">Oldest Entry</div>
1175 </div>
1176 {/if}
1177 </div>
1178 {:else}
1179 <div class="empty-state compact">
1180 <p>No statistics available</p>
1181 </div>
1182 {/if}
1183 </div>
1184 </section>
1185 </div>
1186 </div>
1187
1188 <Footer />
1189</div>
1190
1191<style>
1192.homepage-shell {
1193 min-height: 100vh;
1194 color: var(--dynamic-text);
1195 display: flex;
1196 flex-direction: column;
1197 padding: clamp(1.5rem, 4vw, 2.5rem);
1198 font-family:
1199 "JetBrains Mono",
1200 "Fira Code",
1201 "SFMono-Regular",
1202 Menlo,
1203 monospace;
1204 line-height: 1.45;
1205}
1206
1207.homepage-shell * {
1208 box-sizing: border-box;
1209}
1210
1211.terminal-frame {
1212 flex: 1;
1213 width: 100%;
1214 max-width: 1160px;
1215 margin: 0 auto;
1216 display: flex;
1217 flex-direction: column;
1218 border: 1px solid var(--dynamic-border);
1219 background: color-mix(in srgb, var(--dynamic-surface) 80%, var(--dynamic-bg) 20%);
1220 box-shadow:
1221 inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 30%, transparent),
1222 0 16px 28px -24px rgb(0 0 0 / 0.6);
1223}
1224
1225.terminal-header {
1226 display: flex;
1227 align-items: center;
1228 justify-content: space-between;
1229 gap: 1rem;
1230 padding: 0.85rem 1.1rem;
1231 border-bottom: 1px solid var(--dynamic-border);
1232 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1233 text-transform: uppercase;
1234 letter-spacing: 0.08em;
1235 font-size: 0.78rem;
1236}
1237
1238.terminal-heading {
1239 display: flex;
1240 flex-direction: column;
1241 gap: 0.35rem;
1242}
1243
1244.terminal-path {
1245 font-weight: 600;
1246 color: var(--dynamic-text);
1247}
1248
1249.terminal-meta {
1250 color: var(--dynamic-muted);
1251 font-size: 0.72rem;
1252 letter-spacing: 0.12em;
1253}
1254
1255.theme-toggle-button {
1256 width: 2.5rem;
1257 height: 2.5rem;
1258 display: inline-flex;
1259 align-items: center;
1260 justify-content: center;
1261 border: 1px solid var(--dynamic-border);
1262 background-color: var(--dynamic-bg);
1263 color: var(--dynamic-muted);
1264 transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease;
1265 border-radius: 0;
1266 cursor: pointer;
1267}
1268
1269.theme-toggle-button:hover,
1270.theme-toggle-button:focus-visible {
1271 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1272 color: var(--dynamic-text);
1273 background-color: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1274 outline: none;
1275}
1276
1277.theme-toggle-button svg {
1278 width: 1.3rem;
1279 height: 1.3rem;
1280}
1281
1282.sr-only {
1283 position: absolute;
1284 width: 1px;
1285 height: 1px;
1286 padding: 0;
1287 margin: -1px;
1288 overflow: hidden;
1289 clip: rect(0, 0, 0, 0);
1290 white-space: nowrap;
1291 border: 0;
1292}
1293
1294.terminal-body {
1295 display: flex;
1296 flex-direction: column;
1297 gap: 1.25rem;
1298 padding: 1.25rem 1.5rem 1.5rem;
1299 background: var(--dynamic-bg);
1300}
1301
1302.terminal-status-row {
1303 display: flex;
1304 flex-wrap: wrap;
1305 gap: 0.75rem;
1306}
1307
1308.status-chip {
1309 display: inline-flex;
1310 align-items: center;
1311 gap: 0.5rem;
1312 padding: 0.45rem 0.8rem;
1313 border: 1px solid var(--dynamic-border);
1314 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1315 font-size: 0.68rem;
1316 letter-spacing: 0.1em;
1317 text-transform: uppercase;
1318 color: var(--dynamic-muted);
1319}
1320
1321.status-chip .chip-value {
1322 color: var(--dynamic-text);
1323 font-weight: 600;
1324}
1325
1326.last-visit-banner {
1327 margin-top: 0.9rem;
1328 display: grid;
1329 grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(120px, auto) minmax(120px, auto) minmax(120px, auto);
1330 gap: 0.75rem;
1331 align-items: center;
1332 padding: 0.75rem 1rem;
1333 border: 1px solid var(--dynamic-border);
1334 background: color-mix(in srgb, var(--dynamic-bg) 90%, var(--dynamic-surface) 10%);
1335 color: var(--dynamic-text);
1336 text-transform: uppercase;
1337 letter-spacing: 0.08em;
1338 transition: background-color 150ms ease, border-color 150ms ease;
1339 cursor: pointer;
1340}
1341
1342.last-visit-banner:hover,
1343.last-visit-banner:focus-visible {
1344 background: color-mix(in srgb, var(--dynamic-bg) 72%, var(--dynamic-surface) 28%);
1345 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1346 outline: none;
1347}
1348
1349.last-visit-banner[tabindex="-1"],
1350.last-visit-banner[tabindex="-1"]:hover {
1351 cursor: default;
1352 background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
1353}
1354
1355.banner-label {
1356 color: var(--dynamic-muted);
1357 font-size: 0.68rem;
1358}
1359
1360.banner-title {
1361 font-weight: 600;
1362 white-space: nowrap;
1363 overflow: hidden;
1364 letter-spacing: 0.06em;
1365}
1366
1367:global(.banner-title.truncated) {
1368 mask-image: linear-gradient(to right, black 85%, transparent 100%);
1369 -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1370}
1371
1372.banner-domain {
1373 color: var(--dynamic-muted);
1374 font-size: 0.68rem;
1375 letter-spacing: 0.1em;
1376}
1377
1378.banner-time {
1379 font-size: 0.72rem;
1380 color: var(--dynamic-text);
1381 white-space: nowrap;
1382}
1383
1384.banner-absolute {
1385 font-size: 0.68rem;
1386 color: var(--dynamic-muted);
1387 white-space: nowrap;
1388}
1389
1390.banner-empty {
1391 grid-column: 2 / -1;
1392 color: var(--dynamic-muted);
1393 font-size: 0.72rem;
1394 letter-spacing: 0.1em;
1395}
1396
1397.terminal-grid {
1398 display: grid;
1399 gap: 1rem;
1400}
1401
1402.main-panels {
1403 grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
1404}
1405
1406.terminal-pane {
1407 border: 1px solid var(--dynamic-border);
1408 background: color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%);
1409 display: flex;
1410 flex-direction: column;
1411 min-height: 0;
1412 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 18%, transparent);
1413}
1414
1415.terminal-pane.full-width {
1416 width: 100%;
1417 grid-column: 1 / -1;
1418}
1419
1420.panel-header {
1421 display: flex;
1422 flex-direction: column;
1423 gap: 0.3rem;
1424 padding: 1rem 1.1rem;
1425 border-bottom: 1px solid var(--dynamic-border);
1426 background: color-mix(in srgb, var(--dynamic-bg) 75%, transparent);
1427}
1428
1429.panel-title {
1430 margin: 0;
1431 font-size: 0.95rem;
1432 text-transform: uppercase;
1433 letter-spacing: 0.08em;
1434 color: var(--dynamic-text);
1435}
1436
1437.panel-subtitle {
1438 margin: 0;
1439 font-size: 0.72rem;
1440 color: var(--dynamic-muted);
1441 letter-spacing: 0.1em;
1442 text-transform: uppercase;
1443}
1444
1445.panel-body {
1446 padding: 1.1rem 1.1rem 1.25rem;
1447 display: flex;
1448 flex-direction: column;
1449 gap: 1.1rem;
1450 min-height: 0;
1451 overflow: hidden;
1452}
1453
1454.loading {
1455 padding: 0.9rem;
1456 text-align: center;
1457 color: var(--dynamic-muted);
1458 border: 1px dashed var(--dynamic-border);
1459 background-color: color-mix(in srgb, var(--dynamic-bg) 85%, var(--dynamic-surface) 15%);
1460 text-transform: uppercase;
1461 letter-spacing: 0.08em;
1462 font-size: 0.7rem;
1463}
1464
1465.empty-state {
1466 border: 1px dashed var(--dynamic-border);
1467 padding: 1.4rem;
1468 display: flex;
1469 flex-direction: column;
1470 gap: 0.5rem;
1471 align-items: center;
1472 text-align: center;
1473 color: var(--dynamic-muted);
1474 background-color: color-mix(in srgb, var(--dynamic-bg) 90%, var(--dynamic-surface) 10%);
1475 letter-spacing: 0.06em;
1476}
1477
1478.empty-state h3 {
1479 margin: 0;
1480 color: var(--dynamic-text);
1481 font-size: 0.9rem;
1482 text-transform: uppercase;
1483}
1484
1485.empty-state p {
1486 margin: 0;
1487 font-size: 0.72rem;
1488}
1489
1490.empty-state.compact {
1491 padding: 1rem;
1492}
1493
1494.history-body {
1495 gap: 0.75rem;
1496 display: flex;
1497 flex-direction: column;
1498 min-height: 0;
1499 overflow: hidden;
1500}
1501
1502.history-list {
1503 display: flex;
1504 flex-direction: column;
1505 flex: 1;
1506 min-height: 0;
1507 overflow-y: auto;
1508 border: 1px solid var(--dynamic-border);
1509 background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
1510}
1511
1512.history-item {
1513 display: grid;
1514 grid-template-columns: auto 1fr auto;
1515 align-items: center;
1516 gap: 0.75rem;
1517 padding: 0.6rem 0.9rem;
1518 border-bottom: 1px dashed var(--dynamic-border);
1519 transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
1520}
1521
1522.history-item:last-child {
1523 border-bottom: none;
1524}
1525
1526.history-item:hover,
1527.history-item:focus-visible {
1528 background: color-mix(in srgb, var(--dynamic-bg) 65%, var(--dynamic-surface) 35%);
1529 outline: none;
1530}
1531
1532.history-item.deleting {
1533 opacity: 0.35;
1534 pointer-events: none;
1535 transform: translateX(4px);
1536}
1537
1538.history-item-leading {
1539 width: 38px;
1540 height: 38px;
1541 border: 1px solid var(--dynamic-border);
1542 background-color: var(--dynamic-bg);
1543 display: flex;
1544 align-items: center;
1545 justify-content: center;
1546 flex-shrink: 0;
1547}
1548
1549.history-favicon-img {
1550 width: 60%;
1551 height: 60%;
1552 object-fit: contain;
1553}
1554
1555.history-favicon-fallback {
1556 display: flex;
1557 align-items: center;
1558 justify-content: center;
1559 width: 100%;
1560 height: 100%;
1561 color: var(--dynamic-muted);
1562}
1563
1564.history-favicon-fallback svg {
1565 opacity: 0.55;
1566}
1567
1568.history-item-content {
1569 display: flex;
1570 flex-direction: column;
1571 gap: 0.2rem;
1572 min-width: 0;
1573}
1574
1575.history-item-main {
1576 display: flex;
1577 align-items: center;
1578 justify-content: space-between;
1579 gap: 0.75rem;
1580}
1581
1582.history-title {
1583 font-weight: 600;
1584 color: var(--dynamic-text);
1585 white-space: nowrap;
1586 overflow: hidden;
1587}
1588
1589:global(.history-title.truncated) {
1590 mask-image: linear-gradient(to right, black 85%, transparent 100%);
1591 -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1592}
1593
1594.history-title:hover {
1595 cursor: pointer;
1596}
1597
1598.history-time {
1599 font-size: 0.72rem;
1600 color: var(--dynamic-muted);
1601 text-transform: uppercase;
1602 letter-spacing: 0.08em;
1603 white-space: nowrap;
1604}
1605
1606.history-url {
1607 font-size: 0.72rem;
1608 color: var(--dynamic-muted);
1609 white-space: nowrap;
1610 overflow: hidden;
1611}
1612
1613:global(.history-url.truncated) {
1614 mask-image: linear-gradient(to right, black 85%, transparent 100%);
1615 -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1616}
1617
1618.history-url :global(.history-url-domain) {
1619 color: var(--dynamic-text) !important;
1620 font-weight: 600;
1621}
1622
1623.history-actions {
1624 display: flex;
1625 gap: 0.35rem;
1626}
1627
1628.history-pin,
1629.history-delete {
1630 display: inline-flex;
1631 align-items: center;
1632 justify-content: center;
1633 width: 28px;
1634 height: 28px;
1635 border: 1px solid var(--dynamic-border);
1636 color: var(--dynamic-muted);
1637 background: transparent;
1638 transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
1639}
1640
1641.history-pin:hover,
1642.history-pin:focus-visible,
1643.history-delete:hover,
1644.history-delete:focus-visible {
1645 color: var(--dynamic-text);
1646 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1647 background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1648 outline: none;
1649}
1650
1651.scroll-sentinel {
1652 height: 1px;
1653}
1654
1655.loading-more {
1656 padding: 0.75rem;
1657 font-size: 0.7rem;
1658 text-align: center;
1659 color: var(--dynamic-muted);
1660 letter-spacing: 0.08em;
1661 text-transform: uppercase;
1662}
1663
1664.quick-access-body {
1665 gap: 0.9rem;
1666}
1667
1668.quick-access-grid-compact {
1669 display: flex;
1670 flex-direction: column;
1671 gap: 0.75rem;
1672}
1673
1674.quick-access-item-compact {
1675 border: 1px solid var(--dynamic-border);
1676 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1677 padding: 0.75rem 0.9rem;
1678 display: grid;
1679 grid-template-columns: auto 1fr auto;
1680 gap: 0.75rem;
1681 align-items: center;
1682 transition: background-color 150ms ease, border-color 150ms ease;
1683 cursor: pointer;
1684}
1685
1686.quick-access-item-compact:hover,
1687.quick-access-item-compact:focus-visible {
1688 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1689 background: color-mix(in srgb, var(--dynamic-bg) 70%, var(--dynamic-surface) 30%);
1690 outline: none;
1691}
1692
1693.quick-access-favicon-compact {
1694 width: 36px;
1695 height: 36px;
1696 border: 1px solid var(--dynamic-border);
1697 display: flex;
1698 align-items: center;
1699 justify-content: center;
1700 background: var(--dynamic-bg);
1701}
1702
1703.quick-access-favicon-img-compact {
1704 width: 60%;
1705 height: 60%;
1706 object-fit: contain;
1707}
1708
1709.quick-access-fallback-compact {
1710 display: flex;
1711 align-items: center;
1712 justify-content: center;
1713 width: 100%;
1714 height: 100%;
1715 color: var(--dynamic-muted);
1716}
1717
1718.quick-access-fallback-compact svg {
1719 opacity: 0.55;
1720}
1721
1722.quick-access-meta-compact {
1723 display: flex;
1724 flex-direction: column;
1725 gap: 0.2rem;
1726 min-width: 0;
1727}
1728
1729.quick-access-title-compact {
1730 color: var(--dynamic-text);
1731 font-weight: 600;
1732 white-space: nowrap;
1733 overflow: hidden;
1734}
1735
1736:global(.quick-access-title-compact.truncated) {
1737 mask-image: linear-gradient(to right, black 85%, transparent 100%);
1738 -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1739}
1740
1741.quick-access-title-compact:hover {
1742 cursor: pointer;
1743}
1744
1745.quick-access-domain-compact {
1746 color: var(--dynamic-muted);
1747 font-size: 0.72rem;
1748 text-transform: uppercase;
1749 letter-spacing: 0.08em;
1750}
1751
1752.quick-access-actions-compact {
1753 display: flex;
1754 flex-direction: column;
1755 gap: 0.3rem;
1756 align-items: flex-end;
1757 justify-content: center;
1758}
1759
1760.quick-access-pin-compact {
1761 display: inline-flex;
1762 align-items: center;
1763 justify-content: center;
1764 width: 26px;
1765 height: 26px;
1766 border: 1px solid var(--dynamic-border);
1767 color: var(--dynamic-muted);
1768 background: transparent;
1769 transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
1770 cursor: pointer;
1771}
1772
1773.quick-access-pin-compact:hover,
1774.quick-access-pin-compact:focus-visible {
1775 color: var(--dynamic-text);
1776 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1777 background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1778 outline: none;
1779}
1780
1781.quick-access-pin-compact svg {
1782 width: 14px;
1783 height: 14px;
1784}
1785
1786.quick-access-visits-compact {
1787 font-size: 0.7rem;
1788 color: var(--dynamic-muted);
1789 text-transform: uppercase;
1790 letter-spacing: 0.08em;
1791}
1792
1793.show-more-button {
1794 width: 100%;
1795 padding: 0.65rem 0.75rem;
1796 border: 1px solid var(--dynamic-border);
1797 background: transparent;
1798 color: var(--dynamic-muted);
1799 font-size: 0.72rem;
1800 text-transform: uppercase;
1801 letter-spacing: 0.08em;
1802 cursor: pointer;
1803 transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease;
1804 border-radius: 0;
1805}
1806
1807.show-more-button:hover,
1808.show-more-button:focus-visible {
1809 background: color-mix(in srgb, var(--dynamic-bg) 75%, var(--dynamic-surface) 25%);
1810 color: var(--dynamic-text);
1811 border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1812 outline: none;
1813}
1814
1815.stats-grid-horizontal {
1816 display: grid;
1817 gap: 0.85rem;
1818 grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1819}
1820
1821.stat-item-horizontal {
1822 border: 1px solid var(--dynamic-border);
1823 padding: 0.85rem;
1824 display: flex;
1825 flex-direction: column;
1826 gap: 0.4rem;
1827 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1828 text-transform: uppercase;
1829 letter-spacing: 0.08em;
1830}
1831
1832.stat-value {
1833 font-size: 0.95rem;
1834 color: var(--dynamic-text);
1835 font-weight: 600;
1836}
1837
1838.stat-label {
1839 font-size: 0.68rem;
1840 color: var(--dynamic-muted);
1841}
1842
1843.shortcuts-list {
1844 display: grid;
1845 grid-template-columns: repeat(2, 1fr);
1846 gap: 0.6rem;
1847}
1848
1849.shortcut-item {
1850 display: grid;
1851 grid-template-columns: auto 1fr;
1852 gap: 0.6rem;
1853 align-items: center;
1854 border: 1px solid var(--dynamic-border);
1855 padding: 0.6rem 0.75rem;
1856 background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1857}
1858
1859.shortcut-key-badge {
1860 font-size: 0.72rem;
1861 text-transform: uppercase;
1862 letter-spacing: 0.08em;
1863 padding: 0.25rem 0.6rem;
1864 border: 1px solid var(--dynamic-border);
1865 color: var(--dynamic-text);
1866 background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1867}
1868
1869.shortcut-desc {
1870 font-size: 0.72rem;
1871 color: var(--dynamic-muted);
1872 letter-spacing: 0.06em;
1873}
1874
1875@media (max-width: 960px) {
1876 .terminal-body {
1877 padding: 1.1rem;
1878 }
1879
1880 .terminal-grid {
1881 gap: 0.75rem;
1882 }
1883
1884 .main-panels {
1885 grid-template-columns: 1fr;
1886 }
1887
1888 .last-visit-banner {
1889 grid-template-columns: minmax(0, auto) minmax(0, 1fr);
1890 grid-template-rows: repeat(3, auto);
1891 gap: 0.5rem 0.75rem;
1892 }
1893
1894 .banner-time,
1895 .banner-absolute {
1896 justify-self: flex-start;
1897 }
1898}
1899
1900@media (max-width: 640px) {
1901 .homepage-shell {
1902 padding: 1.25rem;
1903 }
1904
1905 .terminal-header {
1906 flex-direction: column;
1907 align-items: flex-start;
1908 }
1909
1910 .theme-toggle-button {
1911 align-self: flex-end;
1912 }
1913
1914 .terminal-status-row {
1915 gap: 0.5rem;
1916 }
1917
1918 .status-chip {
1919 padding: 0.35rem 0.65rem;
1920 }
1921
1922 .last-visit-banner {
1923 grid-template-columns: minmax(0, 1fr);
1924 grid-template-rows: repeat(4, auto);
1925 }
1926
1927 .banner-label,
1928 .banner-title,
1929 .banner-domain,
1930 .banner-time,
1931 .banner-absolute {
1932 justify-self: flex-start;
1933 }
1934
1935 .shortcuts-list {
1936 grid-template-columns: 1fr;
1937 }
1938}
1939</style>