v0.11.0 dc50343

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

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