v0.12.1 1453d21

Homepage.svelte RawHistoryFolder

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>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close