detached de0f183

Merge pull request #2 from bnema/refacto/ui-improvemenmts

UI improvements and favicon features
commit
de0f183a745bdeaf95ac0ca2b207b05c3077bf14
author
bnema · · 2025-10-07 18:09 UTC
committer
GitHub · · 2025-10-07 18:09 UTC
merge of
a36e3e7 + 434719e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
33 files · +1973 -818
M CHANGELOG.md +14 -0
@@ -5,17 +5,31 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Auto-open omnibox on blank panes**: Omnibox automatically opens when a new pane is created with about:blank, providing immediate search/navigation access
9+- **Configurable default zoom**: Added `default_zoom` config (defaults to 1.2/120%). Per-domain zoom still overrides this
8 - **Pin/favorite sites**: Pin websites from history or quick access to prioritize them in "Jump back in" section. Uses localStorage persistence with star icons for pin/unpin actions10 - **Pin/favorite sites**: Pin websites from history or quick access to prioritize them in "Jump back in" section. Uses localStorage persistence with star icons for pin/unpin actions
9 - **Zellij-style stacked panes**: Added Ctrl+P → 's' to stack panes instead of splitting, with Alt+Up/Down navigation between stacked panes. Features collapsed page title bars showing for inactive panes and full WebView interaction for the active pane. Includes GTK4 CSS styling for proper visual feedback11 - **Zellij-style stacked panes**: Added Ctrl+P → 's' to stack panes instead of splitting, with Alt+Up/Down navigation between stacked panes. Features collapsed page title bars showing for inactive panes and full WebView interaction for the active pane. Includes GTK4 CSS styling for proper visual feedback
12+- **Stacked pane favicons**: Title bars display site favicons with async caching. Click title bars to switch between stacked panes
13+- **Color palette configuration**: Light and dark color palettes now configurable in `appearance` config with semantic tokens (background, surface, text, muted, accent, border). Injected as CSS custom properties at document start
14+- **Search shortcut badges**: Visual badges in omnibox input showing active shortcut prefix (e.g., "gh:", "npm:") with description labels
15+- **Search shortcuts API**: Frontend bridge to fetch search shortcuts from backend `/api/config` endpoint with automatic normalization
10 16
11 ### Changed17 ### Changed
18+- **Terminal-style UI theme**: Monospace fonts (JetBrains Mono, Fira Code), sharp corners (no border-radius), dashed borders, adjusted shadows and spacing throughout omnibox components
19+- **History suggestions**: Use favicon from database when available, omit automatic search shortcuts from suggestions (rely on explicit prefix commands)
20+- **Search shortcuts defaults**: Updated defaults to include ddg, go, mdn, npm packages while reordering others
21+- **Chrome user agent**: Updated to version 141.0.0.0 for better site compatibility
22+- **CSS color system**: Removed hardcoded theme colors, now use dynamic CSS custom properties from Go-injected palette
12 - **History item layout**: Removed "• domain" from titles, now highlight domain within the full URL using same color as title text23 - **History item layout**: Removed "• domain" from titles, now highlight domain within the full URL using same color as title text
13 - **Quick access logic**: Removed 20-item limit, now shows all sites with 2+ visits (pinned sites bypass visit requirement)24 - **Quick access logic**: Removed 20-item limit, now shows all sites with 2+ visits (pinned sites bypass visit requirement)
14 - **History pagination**: Show 15 items initially (up from 10) with "Show N more" button25 - **History pagination**: Show 15 items initially (up from 10) with "Show N more" button
26+- **Homepage layout**: Redesigned with improved visual hierarchy and dynamic color tokens
27+- **Favicon handlers**: All webviews now register favicon change handlers for consistent caching across panes
15 28
16 ### Fixed29 ### Fixed
17 - **Mobile URL overflow**: Fixed long URLs breaking layout on narrow screens by adding proper width constraints and CSS fixes30 - **Mobile URL overflow**: Fixed long URLs breaking layout on narrow screens by adding proper width constraints and CSS fixes
18 - **GTK shortcut GLib-CRITICAL error**: Fixed NULL string assertion in shortcut callback system31 - **GTK shortcut GLib-CRITICAL error**: Fixed NULL string assertion in shortcut callback system
32+- **SVG favicon handling**: Skip SVG favicons to prevent conversion errors (no raster conversion dependencies)
19 - **Focus management architecture**: Refactored workspace focus system from `active` field to `currentlyFocused` with dedicated FocusManager for improved reliability and consistency across pane operations33 - **Focus management architecture**: Refactored workspace focus system from `active` field to `currentlyFocused` with dedicated FocusManager for improved reliability and consistency across pane operations
20 - **Stacked pane focus conflicts**: Fixed focus stealing issues in stacked panes by implementing focus-aware navigation that preserves active pane when spawning siblings34 - **Stacked pane focus conflicts**: Fixed focus stealing issues in stacked panes by implementing focus-aware navigation that preserves active pane when spawning siblings
21 - **WebView title updates**: Enhanced webview title change handling to properly update stacked pane title bars in addition to database storage35 - **WebView title updates**: Enhanced webview title change handling to properly update stacked pane title bars in addition to database storage
M gui/src/components/Footer.svelte +23 -13
@@ -18,53 +18,63 @@
18 18
19 <style>19 <style>
20 .footer {20 .footer {
21- margin-top: 1rem;21+ margin-top: 1.5rem;
22 width: 100%;22 width: 100%;
23+ border-top: 1px solid var(--dynamic-border);
24+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
23 }25 }
24 26
25 .footer-content {27 .footer-content {
26 display: flex;28 display: flex;
27 justify-content: center;29 justify-content: center;
28 align-items: center;30 align-items: center;
29- padding: 1rem 0;31+ padding: 0.75rem 0;
30 }32 }
31 33
32 .footer-text {34 .footer-text {
33 display: flex;35 display: flex;
34 align-items: center;36 align-items: center;
35 gap: 0.75rem;37 gap: 0.75rem;
36- font-size: 0.85rem;38+ font-size: 0.75rem;
37- color: #888;39+ text-transform: uppercase;
40+ letter-spacing: 0.12em;
41+ color: var(--dynamic-muted);
38 }42 }
39 43
40 .footer-name {44 .footer-name {
41- color: #aaa;45+ color: var(--dynamic-text);
42- font-weight: 500;46+ font-weight: 600;
43 }47 }
44 48
45 .footer-dot {49 .footer-dot {
46- color: #666;50+ color: var(--dynamic-border);
47 }51 }
48 52
49 .footer-link {53 .footer-link {
50 background: none;54 background: none;
51 border: none;55 border: none;
52- color: #0066cc;56+ color: var(--dynamic-muted);
53 text-decoration: none;57 text-decoration: none;
54 cursor: pointer;58 cursor: pointer;
55 font-size: 0.85rem;59 font-size: 0.85rem;
56 padding: 0;60 padding: 0;
57- transition: color 0.2s ease;61+ transition: color 0.2s ease, letter-spacing 0.2s ease;
62+ font-family:
63+ "JetBrains Mono",
64+ "Fira Code",
65+ "SFMono-Regular",
66+ Menlo,
67+ monospace;
58 }68 }
59 69
60 .footer-link:hover {70 .footer-link:hover {
61- color: #4d94ff;71+ color: var(--dynamic-text);
62- text-decoration: underline;72+ letter-spacing: 0.16em;
63 }73 }
64 74
65 .footer-link:focus {75 .footer-link:focus {
66- outline: 1px solid #0066cc;76+ outline: 1px solid var(--dynamic-border);
67 outline-offset: 2px;77 outline-offset: 2px;
68- border-radius: 2px;78+ border-radius: 0;
69 }79 }
70 </style>80 </style>
M gui/src/components/omnibox/Omnibox.svelte +66 -36
@@ -177,18 +177,20 @@
177 // defensive: ignore if setting styles fails for some reason177 // defensive: ignore if setting styles fails for some reason
178 }178 }
179 179
180- const surfaceMix = isDarkMode ? '65%' : '92%';180+ const surfaceMix = isDarkMode ? '55%' : '88%';
181- const inputMix = isDarkMode ? '60%' : '94%';181+ const inputMix = isDarkMode ? '52%' : '90%';
182- const borderMix = isDarkMode ? '70%' : '90%';182+ const borderMix = isDarkMode ? '62%' : '85%';
183- const blurMix = isDarkMode ? '28%' : '16%';183+ const blurMix = isDarkMode ? '22%' : '12%';
184 184
185- const containerBackground = faded185+ const applyFade = faded && mode === 'find';
186+
187+ const containerBackground = applyFade
186 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`188 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
187 : 'var(--dynamic-surface)';189 : 'var(--dynamic-surface)';
188- const inputBackground = faded190+ const inputBackground = applyFade
189 ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)`191 ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)`
190 : 'var(--dynamic-bg)';192 : 'var(--dynamic-bg)';
191- const borderColor = faded193+ const borderColor = applyFade
192 ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, transparent)`194 ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, transparent)`
193 : 'var(--dynamic-border)';195 : 'var(--dynamic-border)';
194 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;196 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
@@ -196,7 +198,8 @@
196 boxElement.style.background = containerBackground;198 boxElement.style.background = containerBackground;
197 boxElement.style.color = 'var(--dynamic-text)';199 boxElement.style.color = 'var(--dynamic-text)';
198 boxElement.style.setProperty('--dumber-border-color', borderColor);200 boxElement.style.setProperty('--dumber-border-color', borderColor);
199- boxElement.style.setProperty('--dumber-omnibox-surface', containerBackground);201+ // Force the omnibox to use our neutral surfaces so it stays in sync with the shell palette
202+ boxElement.style.setProperty('--dumber-omnibox-surface', 'color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%)');
200 203
201 inputElement.style.background = inputBackground;204 inputElement.style.background = inputBackground;
202 inputElement.style.color = 'var(--dynamic-text)';205 inputElement.style.color = 'var(--dynamic-text)';
@@ -217,24 +220,17 @@
217 // If computed styles fail, keep fallback accent color220 // If computed styles fail, keep fallback accent color
218 }221 }
219 inputElement.style.setProperty('--dumber-input-accent', accentColor);222 inputElement.style.setProperty('--dumber-input-accent', accentColor);
220- boxElement.style.setProperty('--dumber-input-accent', accentColor);223+ boxElement.style.setProperty('--dumber-input-accent', 'var(--dynamic-muted)');
221 224
222 const isFocused = document.activeElement === inputElement;225 const isFocused = document.activeElement === inputElement;
223 226
224 if (!isFocused) {227 if (!isFocused) {
225- inputElement.style.setProperty('--dumber-input-border-color', borderColor);228+ inputElement.style.setProperty('--dumber-input-border-color', 'var(--dynamic-border)');
226 }229 }
227 230
228- if (faded) {231+ if (applyFade) {
229- const textShadow = isDarkMode
230- ? '1px 1px 1px rgba(0,0,0,0.65)'
231- : '1px 1px 1px rgba(255,255,255,0.65)';
232- inputElement.style.textShadow = textShadow;
233- boxElement.style.textShadow = textShadow;
234 blurLayerElement.style.opacity = '1';232 blurLayerElement.style.opacity = '1';
235 } else {233 } else {
236- inputElement.style.textShadow = '';
237- boxElement.style.textShadow = '';
238 blurLayerElement.style.opacity = '0';234 blurLayerElement.style.opacity = '0';
239 }235 }
240 });236 });
@@ -333,7 +329,7 @@
333 };329 };
334 330
335 console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));331 console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
336- 332+
337 // Check for pending suggestions that arrived before API was ready333 // Check for pending suggestions that arrived before API was ready
338 if ((window as any).__dumber_omnibox_pending_suggestions) {334 if ((window as any).__dumber_omnibox_pending_suggestions) {
339 console.log('🔄 [OMNIBOX] Processing pending suggestions');335 console.log('🔄 [OMNIBOX] Processing pending suggestions');
@@ -341,6 +337,12 @@
341 omniboxBridge.setSuggestions(pending);337 omniboxBridge.setSuggestions(pending);
342 delete (window as any).__dumber_omnibox_pending_suggestions;338 delete (window as any).__dumber_omnibox_pending_suggestions;
343 }339 }
340+
341+ // Auto-open omnibox on about:blank pages
342+ if (window.location.href === 'about:blank') {
343+ console.log('🚀 Auto-opening omnibox for about:blank');
344+ omniboxStore.open('omnibox');
345+ }
344 } catch (error) {346 } catch (error) {
345 console.error('❌ Failed to set up omnibox global API:', error);347 console.error('❌ Failed to set up omnibox global API:', error);
346 }348 }
@@ -388,21 +390,26 @@
388 <!-- Main omnibox container -->390 <!-- Main omnibox container -->
389 <div391 <div
390 bind:this={boxElement}392 bind:this={boxElement}
391- class="dumber-omnibox-container omnibox-base rounded shadow-[0_10px_30px_rgba(0,0,0,0.6)]"393+ class="dumber-omnibox-container omnibox-base terminal-omnibox"
392 style="position: relative !important;394 style="position: relative !important;
393 left: 50% !important;395 left: 50% !important;
394 transform: translateX(-50%) !important;396 transform: translateX(-50%) !important;
395- margin-top: 8vh !important;397+ margin-top: 20vh !important;
396 margin-left: 0 !important;398 margin-left: 0 !important;
397 margin-right: 0 !important;399 margin-right: 0 !important;
398 width: {responsiveStyles.width};400 width: {responsiveStyles.width};
399 padding: {responsiveStyles.padding};401 padding: {responsiveStyles.padding};
400- font-family: 'Fira sans', system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif;402+ font-family: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', Menlo, monospace;
401 pointer-events: auto !important;403 pointer-events: auto !important;
402 box-sizing: border-box !important;404 box-sizing: border-box !important;
403- background: var(--dumber-omnibox-surface, var(--dynamic-surface));405+ background: var(--dumber-omnibox-surface, color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%));
404 color: var(--dynamic-text);406 color: var(--dynamic-text);
405- border: 2px solid var(--dumber-border-color, var(--dynamic-border));407+ border: 1px solid var(--dumber-border-color, var(--dynamic-border));
408+ box-shadow:
409+ 0 20px 60px rgba(0, 0, 0, 0.45),
410+ 0 10px 30px rgba(0, 0, 0, 0.35),
411+ 0 5px 15px rgba(0, 0, 0, 0.25),
412+ inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 22%, transparent);
406 --dumber-border-color: var(--dynamic-border);"413 --dumber-border-color: var(--dynamic-border);"
407 onmousedown={handleBoxClick}414 onmousedown={handleBoxClick}
408 onmouseenter={handleMouseEnter}415 onmouseenter={handleMouseEnter}
@@ -418,7 +425,7 @@
418 bind:this={blurLayerElement}425 bind:this={blurLayerElement}
419 class="dumber-omnibox-blur-layer"426 class="dumber-omnibox-blur-layer"
420 aria-hidden="true"427 aria-hidden="true"
421- style="position: absolute; inset: 0; pointer-events: none; border-radius: inherit; opacity: 0;"428+ style="position: absolute; inset: 0; pointer-events: none; border-radius: 0; opacity: 0;"
422 ></div>429 ></div>
423 <div class="dumber-omnibox-content">430 <div class="dumber-omnibox-content">
424 <!-- Input component -->431 <!-- Input component -->
@@ -436,36 +443,59 @@
436 443
437 <style>444 <style>
438 .dumber-omnibox-container {445 .dumber-omnibox-container {
439- background: var(--dumber-omnibox-surface, var(--dynamic-surface));446+ background: var(--dumber-omnibox-surface, color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%));
440 color: var(--dynamic-text);447 color: var(--dynamic-text);
441- border: 2px solid var(--dumber-border-color, var(--dynamic-border));448+ border: 1px solid var(--dumber-border-color, var(--dynamic-border));
442- transition: background-color 160ms ease, border-color 160ms ease;449+ transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
450+ border-radius: 0;
451+ box-shadow:
452+ 0 20px 60px rgba(0, 0, 0, 0.45),
453+ 0 10px 30px rgba(0, 0, 0, 0.35),
454+ 0 5px 15px rgba(0, 0, 0, 0.25),
455+ inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 22%, transparent);
443 }456 }
444 457
445 .dumber-omnibox-container:focus-within {458 .dumber-omnibox-container:focus-within {
446- border-color: var(--dumber-input-accent, var(--dynamic-accent));459+ border-color: color-mix(in srgb, var(--dumber-border-color, var(--dynamic-border)) 55%, var(--dynamic-text) 45%);
460+ box-shadow:
461+ 0 25px 75px rgba(0, 0, 0, 0.5),
462+ 0 15px 40px rgba(0, 0, 0, 0.4),
463+ 0 8px 20px rgba(0, 0, 0, 0.3),
464+ inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 28%, transparent);
447 }465 }
448 466
449 :global(.dumber-omnibox-container input) {467 :global(.dumber-omnibox-container input) {
450- background: var(--dumber-input-bg, var(--dynamic-bg));468+ background: var(--dumber-input-bg, color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%));
451 color: var(--dynamic-text);469 color: var(--dynamic-text);
452- border-color: var(--dumber-input-border-color, var(--dynamic-border));470+ border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
453- transition: border-color 120ms ease, background-color 120ms ease;471+ transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
472+ font-family:
473+ "JetBrains Mono",
474+ "Fira Code",
475+ "SFMono-Regular",
476+ Menlo,
477+ monospace;
478+ font-size: inherit;
479+ letter-spacing: 0.05em;
480+ text-transform: none;
454 }481 }
455 482
456 :global(.dumber-omnibox-container input::placeholder) {483 :global(.dumber-omnibox-container input::placeholder) {
457 color: var(--dynamic-muted);484 color: var(--dynamic-muted);
485+ letter-spacing: 0.08em;
486+ text-transform: uppercase;
458 }487 }
459 488
460 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */489 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
461 .dumber-omnibox-blur-layer {490 .dumber-omnibox-blur-layer {
462- backdrop-filter: blur(2px) saturate(110%);491+ backdrop-filter: blur(1.5px) saturate(105%);
463- -webkit-backdrop-filter: blur(2px) saturate(110%);492+ -webkit-backdrop-filter: blur(1.5px) saturate(105%);
464- background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 16%, transparent));493+ background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 12%, transparent));
465- transition: opacity 160ms ease;494+ transition: opacity 140ms ease;
466 will-change: opacity;495 will-change: opacity;
467 z-index: 0;496 z-index: 0;
468 mix-blend-mode: normal;497 mix-blend-mode: normal;
498+ border-radius: 0;
469 }499 }
470 500
471 /* Ensure the actual content (input, suggestions) sits above the blur layer */501 /* Ensure the actual content (input, suggestions) sits above the blur layer */
M gui/src/components/omnibox/OmniboxFind.svelte +49 -17
@@ -54,17 +54,9 @@
54 });54 });
55 </script>55 </script>
56 56
57-<div57+<div id="find-list" class="find-list" role="listbox" aria-label="Find results">
58- id="find-list"
59- class="mt-2 max-h-[50vh] overflow-auto border-t border-[#333]"
60- role="listbox"
61- aria-label="Find results"
62->
63 <!-- Header with match count -->58 <!-- Header with match count -->
64- <div59+ <div class="find-header">
65- class="px-2.5 py-1.5 text-xs text-[#bbb]
66- border-b border-[#2a2a2a]"
67- >
68 {#if totalMatches > 0}60 {#if totalMatches > 0}
69 {totalMatches} match{totalMatches === 1 ? '' : 'es'}61 {totalMatches} match{totalMatches === 1 ? '' : 'es'}
70 {:else}62 {:else}
@@ -79,9 +71,7 @@
79 71
80 <div72 <div
81 id="find-item-{index}"73 id="find-item-{index}"
82- class="px-2.5 py-2 cursor-pointer74+ class={isSelected ? 'find-item selected' : 'find-item'}
83- border-b border-[#2a2a2a] last:border-b-0
84- {isSelected ? 'bg-[#0a0a0a]' : ''}"
85 role="option"75 role="option"
86 tabindex="-1"76 tabindex="-1"
87 aria-selected={isSelected}77 aria-selected={isSelected}
@@ -90,10 +80,7 @@
90 onkeydown={(e) => e.key === 'Enter' && handleItemClick(index)}80 onkeydown={(e) => e.key === 'Enter' && handleItemClick(index)}
91 >81 >
92 <!-- Match context -->82 <!-- Match context -->
93- <div83+ <div class="find-context">
94- class="text-[#ddd] text-sm
95- whitespace-nowrap overflow-hidden text-ellipsis"
96- >
97 {match.context || ''}84 {match.context || ''}
98 </div>85 </div>
99 </div>86 </div>
@@ -101,3 +88,48 @@
101 {/if}88 {/if}
102 </div>89 </div>
103 90
91+<style>
92+ .find-list {
93+ margin-top: 0.75rem;
94+ max-height: 50vh;
95+ overflow-y: auto;
96+ border-top: 1px solid var(--dynamic-border);
97+ background: color-mix(in srgb, var(--dynamic-bg) 94%, var(--dynamic-surface) 6%);
98+ }
99+
100+ .find-header {
101+ padding: 0.65rem 0.85rem;
102+ font-size: 0.7rem;
103+ text-transform: uppercase;
104+ letter-spacing: 0.08em;
105+ color: var(--dynamic-muted);
106+ border-bottom: 1px dashed var(--dynamic-border);
107+ }
108+
109+ .find-item {
110+ padding: 0.65rem 0.85rem;
111+ border-bottom: 1px dashed var(--dynamic-border);
112+ cursor: pointer;
113+ transition: background-color 120ms ease, color 120ms ease;
114+ }
115+
116+ .find-item:last-child {
117+ border-bottom: none;
118+ }
119+
120+ .find-item.selected,
121+ .find-item:hover,
122+ .find-item:focus-visible {
123+ background: color-mix(in srgb, var(--dynamic-bg) 75%, var(--dynamic-surface) 25%);
124+ color: var(--dynamic-text);
125+ outline: none;
126+ }
127+
128+ .find-context {
129+ color: var(--dynamic-text);
130+ font-size: 0.75rem;
131+ white-space: nowrap;
132+ overflow: hidden;
133+ text-overflow: ellipsis;
134+ }
135+</style>
M gui/src/components/omnibox/OmniboxInput.svelte +126 -28
@@ -14,6 +14,7 @@
14 let selectedIndex = $derived(omniboxStore.selectedIndex);14 let selectedIndex = $derived(omniboxStore.selectedIndex);
15 let suggestions = $derived(omniboxStore.suggestions);15 let suggestions = $derived(omniboxStore.suggestions);
16 let matches = $derived(omniboxStore.matches);16 let matches = $derived(omniboxStore.matches);
17+ let searchShortcuts = $derived(omniboxStore.searchShortcuts);
17 18
18 // Input element ref and responsive styles (props for parent to bind)19 // Input element ref and responsive styles (props for parent to bind)
19 interface Props {20 interface Props {
@@ -28,6 +29,21 @@
28 29
29 const ACCENT_FALLBACK = 'var(--dynamic-accent)';30 const ACCENT_FALLBACK = 'var(--dynamic-accent)';
30 31
32+ interface CommandBadge {
33+ prefix: string;
34+ label: string;
35+ }
36+
37+ // Build command badges dynamically from search shortcuts
38+ let COMMAND_BADGES = $derived(
39+ Object.entries(searchShortcuts).map(([key, shortcut]) => ({
40+ prefix: `${key}:`,
41+ label: shortcut.description || key.toUpperCase()
42+ }))
43+ );
44+
45+ const BADGE_LEFT_OFFSET = '64px';
46+
31 function resolveAccentColor(): string {47 function resolveAccentColor(): string {
32 if (!inputElement) return ACCENT_FALLBACK;48 if (!inputElement) return ACCENT_FALLBACK;
33 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');49 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
@@ -48,6 +64,50 @@
48 mode === 'find' ? 'Find in page…' : 'Type URL or search…'64 mode === 'find' ? 'Find in page…' : 'Type URL or search…'
49 );65 );
50 66
67+ const DEFAULT_PADDING: [string, string, string, string] = ['10px', '12px', '10px', '12px'];
68+
69+ function normalizePadding(padding?: string): [string, string, string, string] {
70+ if (!padding) return DEFAULT_PADDING;
71+ const parts = padding.trim().split(/\s+/).filter(Boolean);
72+ if (parts.length === 0) return DEFAULT_PADDING;
73+ const p0 = parts[0] ?? DEFAULT_PADDING[0];
74+ const p1 = parts[1] ?? p0;
75+ const p2 = parts[2] ?? p0;
76+ const p3 = parts[3] ?? p1;
77+
78+ if (parts.length === 1) return [p0, p0, p0, p0];
79+ if (parts.length === 2) return [p0, p1, p0, p1];
80+ if (parts.length === 3) return [p0, p1, p2, p1];
81+ return [p0, p1, p2, p3];
82+ }
83+
84+ let activeBadge = $state<CommandBadge | null>(null);
85+ let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
86+ let inputPadding = $state(DEFAULT_PADDING.join(' '));
87+ let baseLeftPadding = $state(DEFAULT_PADDING[3]);
88+ let inputFontSize = $state(responsiveStyles?.fontSize || '16px');
89+
90+ $effect(() => {
91+ inputFontSize = responsiveStyles?.fontSize || '16px';
92+ });
93+
94+ $effect(() => {
95+ basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
96+ });
97+
98+ $effect(() => {
99+ const value = inputValue || '';
100+ const trimmedValue = value.trimStart();
101+ activeBadge = COMMAND_BADGES.find(({ prefix }) => trimmedValue.startsWith(prefix)) || null;
102+ });
103+
104+ $effect(() => {
105+ const [top, right, bottom, left] = basePaddingSegments;
106+ baseLeftPadding = left;
107+ const leftPadding = activeBadge ? `calc(${left} + ${BADGE_LEFT_OFFSET})` : left;
108+ inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;
109+ });
110+
51 // Handle input changes111 // Handle input changes
52 function handleInput(event: Event) {112 function handleInput(event: Event) {
53 const target = event.target as HTMLInputElement;113 const target = event.target as HTMLInputElement;
@@ -133,8 +193,12 @@
133 omniboxStore.selectPrevious();193 omniboxStore.selectPrevious();
134 }194 }
135 195
136- // Fade overlay while navigating results196+ // Fade overlay while navigating results only for find mode
137- omniboxStore.setFaded(true);197+ if (mode === 'find') {
198+ omniboxStore.setFaded(true);
199+ } else {
200+ omniboxStore.setFaded(false);
201+ }
138 202
139 // For find mode, reveal the current match203 // For find mode, reveal the current match
140 if (mode === 'find') {204 if (mode === 'find') {
@@ -218,37 +282,50 @@
218 }282 }
219 </script>283 </script>
220 284
221-<input285+<div class="omnibox-input-wrapper">
222- bind:this={inputElement}286+ {#if activeBadge}
223- type="text"287+ <span
224- {placeholder}288+ class="omnibox-prefix-badge"
225- value={inputValue}289+ aria-hidden="true"
226- class="w-full box-border omnibox-input omnibox-input-field rounded focus:outline-none"290+ style={`left: ${baseLeftPadding};`}
227- style="padding: {responsiveStyles?.inputPadding || '10px 12px'};291+ >{activeBadge.label}</span>
228- font-size: {responsiveStyles?.fontSize || '16px'};292+ {/if}
229- box-sizing: border-box;"293+
230- oninput={handleInput}294+ <input
231- onkeydown={handleKeyDown}295+ bind:this={inputElement}
232- onmousedown={handleMouseDown}296+ type="text"
233- onclick={handleClick}297+ {placeholder}
234- onfocus={handleFocus}298+ value={inputValue}
235- onblur={handleBlur}299+ class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
236- onmouseenter={handleMouseEnter}300+ style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
237- autocomplete="off"301+ oninput={handleInput}
238- spellcheck="false"302+ onkeydown={handleKeyDown}
239- role="combobox"303+ onmousedown={handleMouseDown}
240- aria-expanded={omniboxStore.hasContent}304+ onclick={handleClick}
241- aria-controls="omnibox-list"305+ onfocus={handleFocus}
242- aria-haspopup="listbox"306+ onblur={handleBlur}
243- aria-owns="omnibox-list"307+ onmouseenter={handleMouseEnter}
244- aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}308+ autocomplete="off"
245-/>309+ spellcheck="false"
310+ role="combobox"
311+ aria-expanded={omniboxStore.hasContent}
312+ aria-controls="omnibox-list"
313+ aria-haspopup="listbox"
314+ aria-owns="omnibox-list"
315+ aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
316+ />
317+</div>
246 318
247 <style>319 <style>
320+ .omnibox-input-wrapper {
321+ position: relative;
322+ }
323+
248 .omnibox-input-field {324 .omnibox-input-field {
249 background: var(--dumber-input-bg, var(--dynamic-bg));325 background: var(--dumber-input-bg, var(--dynamic-bg));
250 color: var(--dynamic-text);326 color: var(--dynamic-text);
251- border: 2px solid var(--dumber-input-border-color, var(--dynamic-border));327+ border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
328+ border-radius: 0;
252 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;329 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
253 }330 }
254 331
@@ -259,4 +336,25 @@
259 .omnibox-input-field:focus {336 .omnibox-input-field:focus {
260 color: var(--dynamic-text);337 color: var(--dynamic-text);
261 }338 }
339+
340+ .omnibox-prefix-badge {
341+ position: absolute;
342+ top: 50%;
343+ transform: translateY(-50%);
344+ padding: 0.2rem 0.6rem;
345+ border-radius: 0;
346+ border: 1px solid var(--dynamic-border, transparent);
347+ background: var(--dumber-input-badge-bg, var(--dynamic-accent));
348+ color: var(--dumber-input-badge-text, var(--dynamic-bg));
349+ font-size: 0.75rem;
350+ font-weight: 600;
351+ display: inline-flex;
352+ align-items: center;
353+ gap: 4px;
354+ line-height: 1;
355+ pointer-events: none;
356+ white-space: nowrap;
357+ box-shadow: 0 0 0 1px var(--dynamic-border, transparent);
358+ z-index: 1;
359+ }
262 </style>360 </style>
M gui/src/components/omnibox/OmniboxSuggestions.svelte +85 -20
@@ -20,7 +20,7 @@
20 // Handle suggestion item mouse enter20 // Handle suggestion item mouse enter
21 function handleItemMouseEnter(index: number) {21 function handleItemMouseEnter(index: number) {
22 omniboxStore.setSelectedIndex(index);22 omniboxStore.setSelectedIndex(index);
23- omniboxStore.setFaded(true);23+ omniboxStore.setFaded(false);
24 scrollToSelection();24 scrollToSelection();
25 }25 }
26 26
@@ -86,7 +86,7 @@
86 {#if hasContent}86 {#if hasContent}
87 <div87 <div
88 id="omnibox-list"88 id="omnibox-list"
89- class="mt-2 max-h-[50vh] overflow-auto border-t border-[#333]"89+ class="suggestion-list"
90 role="listbox"90 role="listbox"
91 aria-label="Search suggestions"91 aria-label="Search suggestions"
92 >92 >
@@ -96,9 +96,7 @@
96 96
97 <div97 <div
98 id="omnibox-item-{index}"98 id="omnibox-item-{index}"
99- class="px-2.5 py-2 flex gap-2.5 items-center cursor-pointer99+ class={isSelected ? 'suggestion-item selected' : 'suggestion-item'}
100- border-b border-[#2a2a2a] last:border-b-0
101- {isSelected ? 'bg-[#0a0a0a]' : ''}"
102 role="option"100 role="option"
103 tabindex={isSelected ? 0 : -1}101 tabindex={isSelected ? 0 : -1}
104 aria-selected={isSelected}102 aria-selected={isSelected}
@@ -108,15 +106,11 @@
108 >106 >
109 <!-- Favicon chip -->107 <!-- Favicon chip -->
110 {#if suggestion.favicon}108 {#if suggestion.favicon}
111- <div109+ <div class="suggestion-favicon">
112- class="flex-shrink-0 w-5 h-5 rounded-full
113- bg-[#ccc] border border-black/12
114- shadow-sm flex items-center justify-center"
115- >
116 <img110 <img
117 src={suggestion.favicon}111 src={suggestion.favicon}
118 alt=""112 alt=""
119- class="w-4 h-4 filter brightness-105 contrast-103"113+ class="suggestion-favicon-img"
120 loading="lazy"114 loading="lazy"
121 onerror={handleFaviconError}115 onerror={handleFaviconError}
122 style="image-rendering: -webkit-optimize-contrast;"116 style="image-rendering: -webkit-optimize-contrast;"
@@ -125,24 +119,19 @@
125 {/if}119 {/if}
126 120
127 <!-- URL text with gradient fade -->121 <!-- URL text with gradient fade -->
128- <div122+ <div class="suggestion-text">
129- class="flex-1 min-w-0 flex gap-2 items-center
130- whitespace-nowrap overflow-hidden
131- [mask-image:linear-gradient(90deg,black_85%,transparent_100%)]
132- [-webkit-mask-image:linear-gradient(90deg,black_85%,transparent_100%)]"
133- >
134 <!-- Domain -->123 <!-- Domain -->
135- <span class="text-[#e6e6e6] opacity-95">124+ <span class="suggestion-domain">
136 {domain}125 {domain}
137 </span>126 </span>
138 127
139 <!-- Separator -->128 <!-- Separator -->
140- <span class="text-[#777]">129+ <span class="suggestion-separator">
141 {' | '}130 {' | '}
142 </span>131 </span>
143 132
144 <!-- Path -->133 <!-- Path -->
145- <span class="text-[#99aadd]">134+ <span class="suggestion-path">
146 {path || '/'}135 {path || '/'}
147 </span>136 </span>
148 </div>137 </div>
@@ -151,3 +140,79 @@
151 </div>140 </div>
152 {/if}141 {/if}
153 142
143+
144+
145+<style>
146+ .suggestion-list {
147+ margin-top: 0.75rem;
148+ max-height: 50vh;
149+ overflow-y: auto;
150+ border-top: 1px solid var(--dynamic-border);
151+ background: color-mix(in srgb, var(--dynamic-bg) 94%, var(--dynamic-surface) 6%);
152+ }
153+
154+ .suggestion-item {
155+ display: flex;
156+ gap: 0.75rem;
157+ align-items: center;
158+ padding: 0.65rem 0.85rem;
159+ border-bottom: 1px dashed var(--dynamic-border);
160+ cursor: pointer;
161+ transition: background-color 120ms ease, color 120ms ease;
162+ letter-spacing: 0.04em;
163+ }
164+
165+ .suggestion-item:last-child {
166+ border-bottom: none;
167+ }
168+
169+ .suggestion-item.selected,
170+ .suggestion-item:hover,
171+ .suggestion-item:focus-visible {
172+ background: color-mix(in srgb, var(--dynamic-bg) 60%, var(--dynamic-surface) 40%);
173+ color: var(--dynamic-text);
174+ outline: none;
175+ }
176+
177+ .suggestion-item .suggestion-domain {
178+ color: var(--dynamic-text);
179+ opacity: 0.95;
180+ }
181+
182+ .suggestion-item .suggestion-separator {
183+ color: var(--dynamic-muted);
184+ }
185+
186+ .suggestion-item .suggestion-path {
187+ color: var(--dynamic-muted);
188+ }
189+
190+ .suggestion-favicon {
191+ width: 20px;
192+ height: 20px;
193+ border: 1px solid var(--dynamic-border);
194+ display: flex;
195+ align-items: center;
196+ justify-content: center;
197+ background: var(--dynamic-bg);
198+ }
199+
200+ .suggestion-favicon-img {
201+ width: 16px;
202+ height: 16px;
203+ object-fit: contain;
204+ }
205+
206+ .suggestion-text {
207+ flex: 1;
208+ min-width: 0;
209+ display: flex;
210+ gap: 0.75rem;
211+ align-items: center;
212+ white-space: nowrap;
213+ overflow: hidden;
214+ text-overflow: ellipsis;
215+ mask-image: linear-gradient(90deg, black 85%, transparent 100%);
216+ -webkit-mask-image: linear-gradient(90deg, black 85%, transparent 100%);
217+ }
218+</style>
M gui/src/components/omnibox/find.ts +1 -1
@@ -23,7 +23,7 @@ function ensureFindStyles(): void {
23 background-color: rgb(255 235 59);23 background-color: rgb(255 235 59);
24 color: rgb(0 0 0);24 color: rgb(0 0 0);
25 padding: 0 0.125rem;25 padding: 0 0.125rem;
26- border-radius: 0.125rem;26+ border-radius: 0;
27 box-shadow: 0 0 0 1px rgb(200 185 0) inset;27 box-shadow: 0 0 0 1px rgb(200 185 0) inset;
28 }28 }
29 29
M gui/src/components/omnibox/messaging.ts +61 -1
@@ -4,7 +4,7 @@
4 * TypeScript interface for Go-JavaScript communication4 * TypeScript interface for Go-JavaScript communication
5 */5 */
6 6
7-import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from "./types";7+import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, SearchShortcut } from "./types";
8 import { omniboxStore } from "./stores.svelte.ts";8 import { omniboxStore } from "./stores.svelte.ts";
9 9
10 export class OmniboxBridge implements OmniboxMessageBridge {10 export class OmniboxBridge implements OmniboxMessageBridge {
@@ -46,6 +46,14 @@ export class OmniboxBridge implements OmniboxMessageBridge {
46 omniboxStore.updateSuggestions(suggestions);46 omniboxStore.updateSuggestions(suggestions);
47 }47 }
48 48
49+ /**
50+ * Update search shortcuts from Go backend
51+ */
52+ setSearchShortcuts(shortcuts: Record<string, SearchShortcut>): void {
53+ console.log("📝 [DEBUG] Received search shortcuts from backend:", shortcuts);
54+ omniboxStore.updateSearchShortcuts(shortcuts);
55+ }
56+
49 /**57 /**
50 * Handle navigation request58 * Handle navigation request
51 */59 */
@@ -65,6 +73,58 @@ export class OmniboxBridge implements OmniboxMessageBridge {
65 this.postMessage({ type: "query", q, limit: lim });73 this.postMessage({ type: "query", q, limit: lim });
66 }74 }
67 75
76+ /**
77+ * Fetch search shortcuts from backend via messaging bridge
78+ */
79+ async fetchSearchShortcuts(): Promise<void> {
80+ return new Promise((resolve, reject) => {
81+ // Set up one-time response handler
82+ const originalCallback = (window as any).__dumber_search_shortcuts;
83+ (window as any).__dumber_search_shortcuts = (data: unknown) => {
84+ try {
85+ // Type guard
86+ if (typeof data !== "object" || data === null) {
87+ reject(new Error("Invalid shortcuts data"));
88+ return;
89+ }
90+ const dataObj = data as Record<string, unknown>;
91+ // Normalize to match our SearchShortcut type
92+ const normalized: Record<string, SearchShortcut> = {};
93+ for (const [key, value] of Object.entries(dataObj)) {
94+ const v = value as Record<string, unknown>;
95+ normalized[key] = {
96+ url: (v.url ?? v.URL ?? "") as string,
97+ description: (v.description ?? v.Description ?? "") as string,
98+ };
99+ }
100+ this.setSearchShortcuts(normalized);
101+
102+ // Restore original callback if it existed
103+ if (originalCallback) {
104+ (window as any).__dumber_search_shortcuts = originalCallback;
105+ }
106+
107+ resolve();
108+ } catch (error) {
109+ console.error("Failed to process search shortcuts:", error);
110+ reject(error);
111+ }
112+ };
113+
114+ // Send message to Go backend
115+ const bridge = window.webkit?.messageHandlers?.dumber;
116+ if (bridge && typeof bridge.postMessage === "function") {
117+ bridge.postMessage(
118+ JSON.stringify({
119+ type: "get_search_shortcuts",
120+ }),
121+ );
122+ } else {
123+ reject(new Error("WebKit message handler not available"));
124+ }
125+ });
126+ }
127+
68 // Suggestions are returned via setSuggestions() when native handler responds128 // Suggestions are returned via setSuggestions() when native handler responds
69 }129 }
70 130
M gui/src/components/omnibox/stores.svelte.ts +9 -0
@@ -10,6 +10,7 @@ import type {
10 FindMatch,10 FindMatch,
11 HighlightNode,11 HighlightNode,
12 OmniboxConfig,12 OmniboxConfig,
13+ SearchShortcut,
13 } from "./types";14 } from "./types";
14 15
15 // Default configuration16 // Default configuration
@@ -31,6 +32,7 @@ let inputValue = $state("");
31 let highlightNodes = $state<HighlightNode[]>([]);32 let highlightNodes = $state<HighlightNode[]>([]);
32 let prevOverflow = $state("");33 let prevOverflow = $state("");
33 let debounceTimer = $state(0);34 let debounceTimer = $state(0);
35+let searchShortcuts = $state<Record<string, SearchShortcut>>({});
34 36
35 // Configuration37 // Configuration
36 let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);38 let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
@@ -70,6 +72,9 @@ export const omniboxStore = {
70 get config() {72 get config() {
71 return config;73 return config;
72 },74 },
75+ get searchShortcuts() {
76+ return searchShortcuts;
77+ },
73 78
74 // Computed getters79 // Computed getters
75 get hasContent() {80 get hasContent() {
@@ -233,6 +238,10 @@ export const omniboxStore = {
233 config = { ...config, ...newConfig };238 config = { ...config, ...newConfig };
234 },239 },
235 240
241+ updateSearchShortcuts(shortcuts: Record<string, SearchShortcut>) {
242+ searchShortcuts = shortcuts;
243+ },
244+
236 reset() {245 reset() {
237 visible = false;246 visible = false;
238 mode = "omnibox";247 mode = "omnibox";
M gui/src/components/omnibox/types.ts +7 -1
@@ -9,8 +9,13 @@ export interface Suggestion {
9 favicon?: string;9 favicon?: string;
10 }10 }
11 11
12+export interface SearchShortcut {
13+ url: string;
14+ description: string;
15+}
16+
12 export interface OmniboxMessage {17 export interface OmniboxMessage {
13- type: "navigate" | "query";18+ type: "navigate" | "query" | "get_search_shortcuts";
14 url?: string;19 url?: string;
15 q?: string;20 q?: string;
16 limit?: number;21 limit?: number;
@@ -45,6 +50,7 @@ export interface OmniboxState {
45 inputValue: string;50 inputValue: string;
46 highlightNodes: HighlightNode[];51 highlightNodes: HighlightNode[];
47 prevOverflow: string;52 prevOverflow: string;
53+ searchShortcuts: Record<string, SearchShortcut>;
48 }54 }
49 55
50 // Message bridge interface for Go communication56 // Message bridge interface for Go communication
M gui/src/components/toast/Toast.svelte +17 -52
@@ -95,10 +95,13 @@
95 95
96 <style>96 <style>
97 .toast {97 .toast {
98- background-color: var(--dynamic-surface);98+ --toast-bg: color-mix(in srgb, var(--dynamic-surface) 88%, var(--dynamic-text) 12%);
99- border: 1px solid var(--dynamic-border);99+ --toast-border: color-mix(in srgb, var(--dynamic-border) 70%, var(--dynamic-text) 30%);
100+ --toast-text: var(--dynamic-text);
101+ background-color: var(--toast-bg);
102+ border: 1px solid var(--toast-border);
100 border-radius: 0;103 border-radius: 0;
101- box-shadow: 0 8px 12px rgba(0, 0, 0, 0.08);104+ box-shadow: 0 8px 14px rgba(0, 0, 0, 0.08);
102 cursor: pointer;105 cursor: pointer;
103 display: inline-flex;106 display: inline-flex;
104 margin: 0;107 margin: 0;
@@ -118,7 +121,7 @@
118 }121 }
119 122
120 .toast-message {123 .toast-message {
121- color: var(--dynamic-text);124+ color: var(--toast-text);
122 font-size: 0.875rem;125 font-size: 0.875rem;
123 font-weight: 600;126 font-weight: 600;
124 min-width: 9rem;127 min-width: 9rem;
@@ -126,57 +129,19 @@
126 text-align: center;129 text-align: center;
127 }130 }
128 131
129- .toast-light.toast-info {132+ .toast.toast-info {
130- background-color: rgb(239 246 255);133+ --toast-bg: color-mix(in srgb, var(--dynamic-surface) 82%, var(--dynamic-text) 18%);
131- border-color: rgb(191 219 254);134+ --toast-border: color-mix(in srgb, var(--dynamic-border) 55%, var(--dynamic-text) 45%);
132 }135 }
133 136
134- .toast-dark.toast-info {137+ .toast.toast-success {
135- background-color: rgb(59 130 246 / 0.2);138+ --toast-bg: color-mix(in srgb, var(--dynamic-surface) 78%, var(--dynamic-text) 22%);
136- border-color: rgb(59 130 246);139+ --toast-border: color-mix(in srgb, var(--dynamic-border) 50%, var(--dynamic-text) 50%);
137 }140 }
138 141
139- .toast-light.toast-success {142+ .toast.toast-error {
140- background-color: rgb(240 253 244);143+ --toast-bg: color-mix(in srgb, var(--dynamic-surface) 74%, var(--dynamic-text) 26%);
141- border-color: rgb(187 247 208);144+ --toast-border: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
142- }145+ --toast-text: color-mix(in srgb, var(--dynamic-text) 80%, var(--dynamic-muted) 20%);
143-
144- .toast-dark.toast-success {
145- background-color: rgb(22 163 74 / 0.2);
146- border-color: rgb(22 163 74);
147- }
148-
149- .toast-light.toast-error {
150- background-color: rgb(254 242 242);
151- border-color: rgb(254 202 202);
152- }
153-
154- .toast-dark.toast-error {
155- background-color: rgb(220 38 38 / 0.2);
156- border-color: rgb(220 38 38);
157- }
158-
159- .toast-light.toast-info .toast-message {
160- color: rgb(59 130 246);
161- }
162-
163- .toast-dark.toast-info .toast-message {
164- color: rgb(191 219 254);
165- }
166-
167- .toast-light.toast-success .toast-message {
168- color: rgb(22 163 74);
169- }
170-
171- .toast-dark.toast-success .toast-message {
172- color: rgb(187 247 208);
173- }
174-
175- .toast-light.toast-error .toast-message {
176- color: rgb(220 38 38);
177- }
178-
179- .toast-dark.toast-error .toast-message {
180- color: rgb(254 202 202);
181 }146 }
182 </style>147 </style>
M gui/src/injected/main-world.ts +144 -0
@@ -19,6 +19,12 @@ declare global {
19 __dumber_dom_zoom_level?: number;19 __dumber_dom_zoom_level?: number;
20 __dumber_initial_theme?: string;20 __dumber_initial_theme?: string;
21 __dumber_setTheme?: (theme: "light" | "dark") => void;21 __dumber_setTheme?: (theme: "light" | "dark") => void;
22+ __dumber_applyPalette?: (theme: "light" | "dark") => void;
23+ __dumber_palette?: Record<string, Record<string, string>>;
24+ __dumber_color_palettes?: (palettes: unknown) => void;
25+ __dumber_color_palettes_error?: (error: string) => void;
26+ __dumber_search_shortcuts?: (data: unknown) => void;
27+ __dumber_search_shortcuts_error?: (error: string) => void;
22 __dumber_applyDomZoom?: (level: number) => void;28 __dumber_applyDomZoom?: (level: number) => void;
23 __dumber_showToast?: (29 __dumber_showToast?: (
24 message: string,30 message: string,
@@ -118,6 +124,134 @@ function detectWindowType(features?: string | null): string {
118 window.__dumber_webview_id = "__WEBVIEW_ID__";124 window.__dumber_webview_id = "__WEBVIEW_ID__";
119 window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;125 window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
120 126
127+ // Request color palettes from Go backend
128+ try {
129+ console.log("[dumber-palette] Initializing color palette loading");
130+ const bridge = window.webkit?.messageHandlers?.dumber;
131+
132+ if (!bridge || typeof bridge.postMessage !== "function") {
133+ console.error("[dumber-palette] ERROR: No webkit message bridge available");
134+ return;
135+ }
136+
137+ console.log("[dumber-palette] WebKit bridge found, setting up handlers");
138+
139+ // Set up callback handler before requesting
140+ window.__dumber_color_palettes = (palettes: unknown) => {
141+ console.log("[dumber-palette] Received palettes from backend:", palettes);
142+ try {
143+ // Type guard to ensure palettes is a valid object
144+ if (typeof palettes !== "object" || palettes === null) {
145+ console.error("[dumber-palette] ERROR: Invalid palettes received - type:", typeof palettes);
146+ return;
147+ }
148+
149+ window.__dumber_palette = palettes as Record<string, Record<string, string>>;
150+ console.log("[dumber-palette] Palette keys:", Object.keys(window.__dumber_palette || {}));
151+
152+ // Normalize palette keys to lowercase
153+ const normalized: Record<string, Record<string, string>> = {};
154+ if (window.__dumber_palette) {
155+ for (const key in window.__dumber_palette) {
156+ if (Object.prototype.hasOwnProperty.call(window.__dumber_palette, key)) {
157+ const paletteValue = window.__dumber_palette[key];
158+ if (paletteValue) {
159+ normalized[key.toLowerCase()] = paletteValue;
160+ console.log(`[dumber-palette] Normalized: ${key} -> ${key.toLowerCase()}, colors:`, Object.keys(paletteValue));
161+ }
162+ }
163+ }
164+ window.__dumber_palette = normalized;
165+ }
166+
167+ // Apply palette now that we have it
168+ const initialTheme: "light" | "dark" = document.documentElement.classList.contains("dark") ? "dark" : "light";
169+ console.log(`[dumber-palette] Initial theme detected: ${initialTheme}`);
170+ window.__dumber_initial_theme = initialTheme;
171+
172+ if (window.__dumber_applyPalette) {
173+ console.log("[dumber-palette] Applying palette...");
174+ window.__dumber_applyPalette(initialTheme);
175+ } else {
176+ console.error("[dumber-palette] ERROR: __dumber_applyPalette function not found");
177+ }
178+ } catch (err) {
179+ console.error("[dumber-palette] ERROR: Failed to apply received palette:", err);
180+ }
181+ };
182+
183+ window.__dumber_color_palettes_error = (error: string) => {
184+ console.error("[dumber-palette] ERROR from backend:", error);
185+ };
186+
187+ // Request color palettes
188+ console.log("[dumber-palette] Sending get_color_palettes request to backend");
189+ bridge.postMessage(JSON.stringify({ type: "get_color_palettes" }));
190+ console.log("[dumber-palette] Request sent, waiting for response...");
191+ } catch (err) {
192+ console.error("[dumber-palette] EXCEPTION requesting color palettes:", err);
193+ }
194+
195+ // Palette application function
196+ window.__dumber_applyPalette = (theme: "light" | "dark") => {
197+ console.log(`[dumber-palette] applyPalette called with theme: ${theme}`);
198+ try {
199+ const palette = window.__dumber_palette?.[theme] || window.__dumber_palette?.["light"];
200+ if (!palette) {
201+ console.error(`[dumber-palette] ERROR: No palette found for theme "${theme}". Available:`, Object.keys(window.__dumber_palette || {}));
202+ return;
203+ }
204+ console.log(`[dumber-palette] Found palette for theme "${theme}":`, palette);
205+
206+ const tokenMap: Record<string, string> = {
207+ background: "--color-browser-bg",
208+ surface: "--color-browser-surface",
209+ surface_variant: "--color-browser-surface-variant",
210+ text: "--color-browser-text",
211+ muted: "--color-browser-muted",
212+ accent: "--color-browser-accent",
213+ border: "--color-browser-border",
214+ };
215+
216+ const dynamicMap: Record<string, string> = {
217+ background: "--dynamic-bg",
218+ surface: "--dynamic-surface",
219+ surface_variant: "--dynamic-surface-variant",
220+ text: "--dynamic-text",
221+ muted: "--dynamic-muted",
222+ accent: "--dynamic-accent",
223+ border: "--dynamic-border",
224+ };
225+
226+ const root = document.documentElement;
227+ let appliedCount = 0;
228+ for (const key in tokenMap) {
229+ if (Object.prototype.hasOwnProperty.call(tokenMap, key)) {
230+ const colorValue = palette[key];
231+ const tokenVar = tokenMap[key];
232+ const dynamicVar = dynamicMap[key];
233+ if (colorValue && typeof colorValue === "string" && tokenVar) {
234+ root.style.setProperty(tokenVar, colorValue);
235+ if (dynamicVar) {
236+ root.style.setProperty(dynamicVar, colorValue);
237+ }
238+ console.log(`[dumber-palette] Applied ${key}: ${colorValue} -> ${tokenVar}`);
239+ appliedCount++;
240+ } else {
241+ console.warn(`[dumber-palette] Skipped ${key}: colorValue=${colorValue}, tokenVar=${tokenVar}`);
242+ }
243+ }
244+ }
245+
246+ console.log(`[dumber-palette] Applied ${appliedCount} colors successfully`);
247+ document.dispatchEvent(
248+ new CustomEvent("dumber:palette-updated", { detail: { theme } })
249+ );
250+ } catch (err) {
251+ console.error("[dumber-palette] EXCEPTION applying palette:", err);
252+ }
253+ };
254+
121 // Theme setter function for GTK theme integration255 // Theme setter function for GTK theme integration
122 window.__dumber_setTheme = (theme: "light" | "dark") => {256 window.__dumber_setTheme = (theme: "light" | "dark") => {
123 window.__dumber_initial_theme = theme;257 window.__dumber_initial_theme = theme;
@@ -127,6 +261,16 @@ function detectWindowType(features?: string | null): string {
127 } else {261 } else {
128 document.documentElement.classList.remove("dark");262 document.documentElement.classList.remove("dark");
129 }263 }
264+ try {
265+ if (typeof window.__dumber_applyPalette === "function") {
266+ window.__dumber_applyPalette(theme);
267+ }
268+ document.dispatchEvent(
269+ new CustomEvent("dumber:theme-change", { detail: { theme } }),
270+ );
271+ } catch (error) {
272+ console.warn("[dumber] Failed to apply runtime palette", error);
273+ }
130 };274 };
131 275
132 // Initialize unified API object276 // Initialize unified API object
M gui/src/injected/modules/color-scheme.ts +7 -0
@@ -187,6 +187,13 @@ class ColorSchemeManager {
187 } else {187 } else {
188 document.documentElement.classList.remove("dark");188 document.documentElement.classList.remove("dark");
189 }189 }
190+ try {
191+ window.__dumber_applyPalette?.(
192+ this.config.isDark ? "dark" : "light",
193+ );
194+ } catch {
195+ /* noop */
196+ }
190 }197 }
191 }, 100);198 }, 100);
192 }199 }
M gui/src/injected/modules/omnibox.ts +7 -0
@@ -8,6 +8,7 @@ import { mount } from "svelte";
8 import { Omnibox } from "../../components/omnibox";8 import { Omnibox } from "../../components/omnibox";
9 import { ensureShadowMount } from "./shadowHost";9 import { ensureShadowMount } from "./shadowHost";
10 import type { OmniboxConfig } from "../../components/omnibox/types";10 import type { OmniboxConfig } from "../../components/omnibox/types";
11+import { omniboxBridge } from "../../components/omnibox/messaging";
11 12
12 let omniboxComponent: ReturnType<typeof mount> | null = null;13 let omniboxComponent: ReturnType<typeof mount> | null = null;
13 let isInitialized = false;14 let isInitialized = false;
@@ -60,6 +61,12 @@ export async function initializeOmnibox(
60 Object.keys(window.__dumber_omnibox),61 Object.keys(window.__dumber_omnibox),
61 );62 );
62 isInitialized = true;63 isInitialized = true;
64+
65+ // Fetch search shortcuts from backend
66+ omniboxBridge.fetchSearchShortcuts().catch((err) => {
67+ console.warn("Failed to fetch search shortcuts:", err);
68+ });
69+
63 // Notify isolated bundle listeners that omnibox is ready70 // Notify isolated bundle listeners that omnibox is ready
64 try {71 try {
65 document.dispatchEvent(new CustomEvent("dumber:omnibox-ready"));72 document.dispatchEvent(new CustomEvent("dumber:omnibox-ready"));
M gui/src/injected/modules/shadowHost.ts +48 -8
@@ -69,6 +69,41 @@ function ensureHostPresence(host: HTMLElement): void {
69 }69 }
70 70
71 export const GLOBAL_SHADOW_HOST_ID = "dumber-ui-root";71 export const GLOBAL_SHADOW_HOST_ID = "dumber-ui-root";
72+const TOKEN_KEYS = [
73+ "--color-browser-bg",
74+ "--color-browser-surface",
75+ "--color-browser-surface-variant",
76+ "--color-browser-text",
77+ "--color-browser-muted",
78+ "--color-browser-accent",
79+ "--color-browser-border",
80+ "--dynamic-bg",
81+ "--dynamic-surface",
82+ "--dynamic-surface-variant",
83+ "--dynamic-text",
84+ "--dynamic-muted",
85+ "--dynamic-accent",
86+ "--dynamic-border",
87+] as const;
88+
89+function syncHostTokens(host: HTMLElement) {
90+ if (typeof window === "undefined" || typeof document === "undefined") {
91+ return;
92+ }
93+
94+ try {
95+ const root = document.documentElement;
96+ const computed = getComputedStyle(root);
97+ for (const token of TOKEN_KEYS) {
98+ const value = computed.getPropertyValue(token);
99+ if (value && value.trim()) {
100+ host.style.setProperty(token, value.trim());
101+ }
102+ }
103+ } catch (error) {
104+ console.warn("[shadowHost] Failed to sync color tokens", error);
105+ }
106+}
72 107
73 /**108 /**
74 * Ensure and return the global ShadowRoot used by injected UI.109 * Ensure and return the global ShadowRoot used by injected UI.
@@ -87,6 +122,7 @@ export function getGlobalShadowRoot(): ShadowRoot {
87 122
88 cachedHost = host;123 cachedHost = host;
89 ensureHostPresence(host);124 ensureHostPresence(host);
125+ syncHostTokens(host);
90 126
91 const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: "open" });127 const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: "open" });
92 128
@@ -100,6 +136,7 @@ export function getGlobalShadowRoot(): ShadowRoot {
100 "dark",136 "dark",
101 document.documentElement.classList.contains("dark"),137 document.documentElement.classList.contains("dark"),
102 );138 );
139+ syncHostTokens(host);
103 });140 });
104 observer.observe(document.documentElement, {141 observer.observe(document.documentElement, {
105 attributes: true,142 attributes: true,
@@ -109,6 +146,16 @@ export function getGlobalShadowRoot(): ShadowRoot {
109 /* no-op */146 /* no-op */
110 }147 }
111 148
149+ try {
150+ document.addEventListener("dumber:palette-updated", () => {
151+ if (cachedHost) {
152+ syncHostTokens(cachedHost);
153+ }
154+ });
155+ } catch {
156+ /* no-op */
157+ }
158+
112 // Inject a minimal reset and base styles into the shadow root once159 // Inject a minimal reset and base styles into the shadow root once
113 if (!shadowResetApplied.has(shadowRoot)) {160 if (!shadowResetApplied.has(shadowRoot)) {
114 const resetStyle = document.createElement("style");161 const resetStyle = document.createElement("style");
@@ -125,20 +172,13 @@ export function getGlobalShadowRoot(): ShadowRoot {
125 tokensStyle.textContent = `172 tokensStyle.textContent = `
126 :host {173 :host {
127 --dynamic-bg: var(--color-browser-bg);174 --dynamic-bg: var(--color-browser-bg);
175+ --dynamic-surface-variant: var(--color-browser-surface-variant);
128 --dynamic-surface: var(--color-browser-surface);176 --dynamic-surface: var(--color-browser-surface);
129 --dynamic-text: var(--color-browser-text);177 --dynamic-text: var(--color-browser-text);
130 --dynamic-muted: var(--color-browser-muted);178 --dynamic-muted: var(--color-browser-muted);
131 --dynamic-accent: var(--color-browser-accent);179 --dynamic-accent: var(--color-browser-accent);
132 --dynamic-border: var(--color-browser-border);180 --dynamic-border: var(--color-browser-border);
133 }181 }
134- :host(.dark) {
135- --dynamic-bg: rgb(17 24 39);
136- --dynamic-surface: rgb(31 41 55);
137- --dynamic-text: rgb(243 244 246);
138- --dynamic-muted: rgb(156 163 175);
139- --dynamic-accent: rgb(96 165 250);
140- --dynamic-border: rgb(55 65 81);
141- }
142 `;182 `;
143 shadowRoot.appendChild(tokensStyle);183 shadowRoot.appendChild(tokensStyle);
144 184
M gui/src/pages/Homepage.svelte +693 -388
@@ -25,6 +25,11 @@
25 newest_entry?: string;25 newest_entry?: string;
26 }26 }
27 27
28+ interface SearchShortcut {
29+ url: string;
30+ description: string;
31+ }
32+
28 let historyItems = $state<HistoryItem[]>([]);33 let historyItems = $state<HistoryItem[]>([]);
29 let topVisited = $state<HistoryItem[]>([]);34 let topVisited = $state<HistoryItem[]>([]);
30 let shortcuts = $state<Shortcut[]>([]);35 let shortcuts = $state<Shortcut[]>([]);
@@ -33,6 +38,8 @@
33 let topVisitedLoading = $state(true);38 let topVisitedLoading = $state(true);
34 let shortcutsLoading = $state(true);39 let shortcutsLoading = $state(true);
35 let statsLoading = $state(true);40 let statsLoading = $state(true);
41+ let searchShortcuts = $state<Record<string, SearchShortcut>>({});
42+ let searchShortcutsLoading = $state(true);
36 let deletingIds = $state<number[]>([]);43 let deletingIds = $state<number[]>([]);
37 let historyOffset = $state(0);44 let historyOffset = $state(0);
38 let hasMoreHistory = $state(true);45 let hasMoreHistory = $state(true);
@@ -482,6 +489,39 @@
482 489
483 let quickAccess = $derived<HistoryItem[]>((pinnedSites.size, buildQuickAccess()));490 let quickAccess = $derived<HistoryItem[]>((pinnedSites.size, buildQuickAccess()));
484 let quickAccessLoading = $derived(topVisitedLoading || historyLoading);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+ })());
485 525
486 const persistTheme = (mode: ThemeMode) => {526 const persistTheme = (mode: ThemeMode) => {
487 try {527 try {
@@ -589,6 +629,49 @@
589 }629 }
590 };630 };
591 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+
592 // Intersection Observer for infinite scroll675 // Intersection Observer for infinite scroll
593 let historyScrollSentinel = $state<HTMLElement | null>(null);676 let historyScrollSentinel = $state<HTMLElement | null>(null);
594 677
@@ -613,6 +696,38 @@
613 return () => observer.disconnect();696 return () => observer.disconnect();
614 };697 };
615 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+
616 onMount(() => {731 onMount(() => {
617 loadPinnedSites();732 loadPinnedSites();
618 initializeShortcuts();733 initializeShortcuts();
@@ -622,6 +737,7 @@
622 });737 });
623 fetchStats();738 fetchStats();
624 fetchTopVisited();739 fetchTopVisited();
740+ fetchSearchShortcuts();
625 741
626 // Theme synchronization742 // Theme synchronization
627 const syncThemeState = () => {743 const syncThemeState = () => {
@@ -648,11 +764,37 @@
648 <svelte:head>764 <svelte:head>
649 <title>Dumber Browser - Homepage</title>765 <title>Dumber Browser - Homepage</title>
650 <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" />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>`}
651 </svelte:head>785 </svelte:head>
652 786
653 <div class="homepage-shell">787 <div class="homepage-shell">
654- <div class="homepage-content">788+ <div class="terminal-frame">
655- <div class="top-bar">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>
656 <button798 <button
657 class="theme-toggle-button"799 class="theme-toggle-button"
658 type="button"800 type="button"
@@ -667,7 +809,9 @@
667 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">809 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
668 <path810 <path
669 fill="currentColor"811 fill="currentColor"
670- d="M21 12.79A9 9 0 0111.21 3 7 7 0 0012 17a7 7 0 009-4.21z"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"
671 />815 />
672 </svg>816 </svg>
673 {:else}817 {:else}
@@ -686,9 +830,57 @@
686 </svg>830 </svg>
687 {/if}831 {/if}
688 </button>832 </button>
689- </div>833+ </header>
690- <div class="main-panels">834+
691- <section class="history-panel brutal-panel">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">
692 <div class="panel-header">884 <div class="panel-header">
693 <h2 class="panel-title">Recent History</h2>885 <h2 class="panel-title">Recent History</h2>
694 <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>886 <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>
@@ -751,10 +943,14 @@
751 </div>943 </div>
752 <div class="history-item-content">944 <div class="history-item-content">
753 <div class="history-item-main">945 <div class="history-item-main">
754- <div class="history-title">{getDisplayTitle(item)}</div>946+ <div class="history-title" use:checkOverflow>
947+ {getDisplayTitle(item)}
948+ </div>
755 <div class="history-time">{formatTime(item.last_visited)}</div>949 <div class="history-time">{formatTime(item.last_visited)}</div>
756 </div>950 </div>
757- <div class="history-url">{@html highlightDomainInUrl(item.url)}</div>951+ <div class="history-url" use:checkOverflow>
952+ {@html highlightDomainInUrl(item.url)}
953+ </div>
758 </div>954 </div>
759 <div class="history-actions">955 <div class="history-actions">
760 <div956 <div
@@ -770,7 +966,7 @@
770 <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"/>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"/>
771 </svg>967 </svg>
772 {:else}968 {:else}
773- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">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">
774 <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"/>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"/>
775 </svg>971 </svg>
776 {/if}972 {/if}
@@ -808,7 +1004,7 @@
808 </div>1004 </div>
809 </section>1005 </section>
810 1006
811- <section class="quick-access-panel brutal-panel">1007+ <section class="quick-access-panel terminal-pane">
812 <div class="panel-header">1008 <div class="panel-header">
813 <h2 class="panel-title">Jump back in</h2>1009 <h2 class="panel-title">Jump back in</h2>
814 <p class="panel-subtitle">Direct access to the sites you like the most.</p>1010 <p class="panel-subtitle">Direct access to the sites you like the most.</p>
@@ -869,7 +1065,9 @@
869 {/if}1065 {/if}
870 </div>1066 </div>
871 <div class="quick-access-meta-compact">1067 <div class="quick-access-meta-compact">
872- <div class="quick-access-title-compact">{getDisplayTitle(item)}</div>1068+ <div class="quick-access-title-compact" use:checkOverflow>
1069+ {getDisplayTitle(item)}
1070+ </div>
873 <div class="quick-access-domain-compact">{getDomain(item.url)}</div>1071 <div class="quick-access-domain-compact">{getDomain(item.url)}</div>
874 </div>1072 </div>
875 <div class="quick-access-actions-compact">1073 <div class="quick-access-actions-compact">
@@ -886,7 +1084,7 @@
886 <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"/>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"/>
887 </svg>1085 </svg>
888 {:else}1086 {:else}
889- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">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">
890 <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"/>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"/>
891 </svg>1089 </svg>
892 {/if}1090 {/if}
@@ -899,132 +1097,189 @@
899 {/if}1097 {/if}
900 </div>1098 </div>
901 </section>1099 </section>
902- </div>
903-
904- <section class="shortcuts-panel brutal-panel">
905- <div class="panel-header">
906- <h2 class="panel-title">Keyboard Shortcuts</h2>
907- <p class="panel-subtitle">Stay quick with the essentials.</p>
908 </div>1100 </div>
909- <div class="panel-body">
910- {#if shortcutsLoading}
911- <div class="loading">Loading shortcuts...</div>
912- {:else}
913- <div class="shortcuts-list">
914- {#each shortcuts as shortcut (shortcut.key)}
915- <div class="shortcut-item">
916- <div class="shortcut-key-badge">{shortcut.key}</div>
917- <div class="shortcut-desc">{shortcut.description}</div>
918- </div>
919- {/each}
920- </div>
921- {/if}
922- </div>
923- </section>
924-
925 1101
926- <section class="stats-panel brutal-panel">1102+ <section class="search-shortcuts-panel terminal-pane full-width">
927- <div class="panel-header">1103+ <div class="panel-header">
928- <h2 class="panel-title">Usage Stats</h2>1104+ <h2 class="panel-title">Search Shortcuts</h2>
929- <p class="panel-subtitle">Your browsing activity at a glance.</p>1105+ <p class="panel-subtitle">Quick search prefixes for faster browsing.</p>
930- </div>1106+ </div>
931- <div class="panel-body">1107+ <div class="panel-body">
932- {#if statsLoading}1108+ {#if searchShortcutsLoading}
933- <div class="loading">Loading stats...</div>1109+ <div class="loading">Loading search shortcuts...</div>
934- {:else if stats}1110+ {:else if Object.keys(searchShortcuts).length === 0}
935- <div class="stats-grid-horizontal">1111+ <div class="empty-state compact">
936- <div class="stat-item-horizontal">1112+ <h3>No shortcuts configured</h3>
937- <div class="stat-value">{stats.total_entries}</div>1113+ <p>Add search shortcuts to your config file.</p>
938- <div class="stat-label">Entries Stored</div>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}
939 </div>1123 </div>
940- <div class="stat-item-horizontal">1124+ {/if}
941- <div class="stat-value">{stats.recent_count}</div>1125+ </div>
942- <div class="stat-label">Recent Window</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}
943 </div>1144 </div>
944- {#if stats.newest_entry}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">
945 <div class="stat-item-horizontal">1159 <div class="stat-item-horizontal">
946- <div class="stat-value">{formatTime(stats.newest_entry)}</div>1160+ <div class="stat-value">{stats.total_entries}</div>
947- <div class="stat-label">Newest Visit</div>1161+ <div class="stat-label">Entries Stored</div>
948 </div>1162 </div>
949- {/if}
950- {#if stats.oldest_entry}
951 <div class="stat-item-horizontal">1163 <div class="stat-item-horizontal">
952- <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>1164+ <div class="stat-value">{stats.recent_count}</div>
953- <div class="stat-label">Oldest Entry</div>1165+ <div class="stat-label">Recent Window</div>
954 </div>1166 </div>
955- {/if}1167+ {#if stats.newest_entry}
956- </div>1168+ <div class="stat-item-horizontal">
957- {:else}1169+ <div class="stat-value">{formatTime(stats.newest_entry)}</div>
958- <div class="empty-state compact">1170+ <div class="stat-label">Newest Visit</div>
959- <p>No statistics available</p>1171+ </div>
960- </div>1172+ {/if}
961- {/if}1173+ {#if stats.oldest_entry}
962- </div>1174+ <div class="stat-item-horizontal">
963- </section>1175+ <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
964-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>
965 </div>1188 </div>
966 1189
967 <Footer />1190 <Footer />
968 </div>1191 </div>
969 1192
970 <style>1193 <style>
971-/* Homepage Layout Styles */
972 .homepage-shell {1194 .homepage-shell {
973 min-height: 100vh;1195 min-height: 100vh;
974 background-color: var(--dynamic-bg);1196 background-color: var(--dynamic-bg);
975 color: var(--dynamic-text);1197 color: var(--dynamic-text);
976 display: flex;1198 display: flex;
977 flex-direction: column;1199 flex-direction: column;
978- padding: clamp(1.75rem, 3vw, 3rem);1200+ padding: clamp(1.5rem, 4vw, 2.5rem);
979- font-family: "Fira Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;1201+ font-family:
1202+ "JetBrains Mono",
1203+ "Fira Code",
1204+ "SFMono-Regular",
1205+ Menlo,
1206+ monospace;
1207+ line-height: 1.45;
980 }1208 }
981 1209
982 .homepage-shell * {1210 .homepage-shell * {
983 box-sizing: border-box;1211 box-sizing: border-box;
984 }1212 }
985 1213
986-.homepage-content {1214+.terminal-frame {
987 flex: 1;1215 flex: 1;
988 width: 100%;1216 width: 100%;
989- max-width: 1180px;1217+ max-width: 1160px;
990 margin: 0 auto;1218 margin: 0 auto;
991 display: flex;1219 display: flex;
992 flex-direction: column;1220 flex-direction: column;
993- gap: clamp(1.75rem, 2.6vw, 2.75rem);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);
994 }1226 }
995 1227
996-.top-bar {1228+.terminal-header {
997 display: flex;1229 display: flex;
998- justify-content: flex-end;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;
999 }1256 }
1000 1257
1001 .theme-toggle-button {1258 .theme-toggle-button {
1002- width: 3rem;1259+ width: 2.5rem;
1003- height: 3rem;1260+ height: 2.5rem;
1004 display: inline-flex;1261 display: inline-flex;
1005 align-items: center;1262 align-items: center;
1006 justify-content: center;1263 justify-content: center;
1007- border: 2px solid var(--dynamic-border);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;
1008 border-radius: 0;1268 border-radius: 0;
1009- background-color: var(--dynamic-surface);
1010- color: var(--dynamic-text);
1011- transition:
1012- background-color 200ms ease,
1013- color 200ms ease,
1014- border-color 200ms ease;
1015 cursor: pointer;1269 cursor: pointer;
1016 }1270 }
1017 1271
1018 .theme-toggle-button:hover,1272 .theme-toggle-button:hover,
1019 .theme-toggle-button:focus-visible {1273 .theme-toggle-button:focus-visible {
1020- border-color: var(--dynamic-accent);1274+ border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1021- color: var(--dynamic-accent);1275+ color: var(--dynamic-text);
1276+ background-color: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1022 outline: none;1277 outline: none;
1023 }1278 }
1024 1279
1025 .theme-toggle-button svg {1280 .theme-toggle-button svg {
1026- width: 1.5rem;1281+ width: 1.3rem;
1027- height: 1.5rem;1282+ height: 1.3rem;
1028 }1283 }
1029 1284
1030 .sr-only {1285 .sr-only {
@@ -1039,99 +1294,159 @@
1039 border: 0;1294 border: 0;
1040 }1295 }
1041 1296
1042-.brutal-panel {1297+.terminal-body {
1043- border: 2px solid var(--dynamic-border);
1044- background-color: var(--dynamic-surface);
1045- border-radius: 0;
1046 display: flex;1298 display: flex;
1047 flex-direction: column;1299 flex-direction: column;
1048- gap: 0;1300+ gap: 1.25rem;
1301+ padding: 1.25rem 1.5rem 1.5rem;
1302+ background: var(--dynamic-bg);
1049 }1303 }
1050 1304
1305+.terminal-status-row {
1306+ display: flex;
1307+ flex-wrap: wrap;
1308+ gap: 0.75rem;
1309+}
1051 1310
1052-1311+.status-chip {
1053-.loading {1312+ display: inline-flex;
1054- padding: 1.25rem;1313+ align-items: center;
1055- text-align: 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;
1056 color: var(--dynamic-muted);1321 color: var(--dynamic-muted);
1057- border: 2px dashed var(--dynamic-border);
1058- background-color: var(--dynamic-bg);
1059 }1322 }
1060 1323
1061-.empty-state {1324+.status-chip .chip-value {
1062- border: 2px dashed var(--dynamic-border);1325+ color: var(--dynamic-text);
1063- padding: 1.75rem;1326+ font-weight: 600;
1064- display: flex;1327+}
1065- flex-direction: column;1328+
1066- gap: 0.5rem;1329+.last-visit-banner {
1067- text-align: center;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;
1068 align-items: center;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 {
1069 color: var(--dynamic-muted);1359 color: var(--dynamic-muted);
1070- background-color: var(--dynamic-bg);1360+ font-size: 0.68rem;
1071 }1361 }
1072 1362
1073-.empty-state h3 {1363+.banner-title {
1074- margin: 0;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;
1075 color: var(--dynamic-text);1383 color: var(--dynamic-text);
1076- font-size: 1.1rem;1384+ white-space: nowrap;
1077 }1385 }
1078 1386
1079-.empty-state p {1387+.banner-absolute {
1080- margin: 0;1388+ font-size: 0.68rem;
1081 color: var(--dynamic-muted);1389 color: var(--dynamic-muted);
1390+ white-space: nowrap;
1082 }1391 }
1083 1392
1084-.empty-state.compact {1393+.banner-empty {
1085- padding: 1.25rem;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;
1086 }1403 }
1087 1404
1088-/* Main panels with height matching */
1089 .main-panels {1405 .main-panels {
1090- display: flex;1406+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
1091- flex-wrap: wrap;
1092- gap: clamp(1.5rem, 2vw, 2rem);
1093- align-items: flex-start;
1094 }1407 }
1095 1408
1096-.history-panel {1409+.terminal-pane {
1097- flex: 1 1 55%;1410+ border: 1px solid var(--dynamic-border);
1098- min-width: min(32rem, 100%);1411+ background: color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%);
1099 display: flex;1412 display: flex;
1100 flex-direction: column;1413 flex-direction: column;
1101 min-height: 0;1414 min-height: 0;
1415+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 18%, transparent);
1102 }1416 }
1103 1417
1104-.quick-access-panel {1418+.terminal-pane.full-width {
1105- flex: 1 1 35%;1419+ width: 100%;
1106- min-width: min(24rem, 100%);1420+ grid-column: 1 / -1;
1107- display: flex;
1108- flex-direction: column;
1109- min-height: 0;
1110 }1421 }
1111 1422
1112 .panel-header {1423 .panel-header {
1113 display: flex;1424 display: flex;
1114 flex-direction: column;1425 flex-direction: column;
1115- gap: 0.4rem;1426+ gap: 0.3rem;
1116- padding: clamp(1.25rem, 2vw, 1.75rem) clamp(1.5rem, 2.5vw, 2rem);1427+ padding: 1rem 1.1rem;
1117- border-bottom: 2px solid var(--dynamic-border);1428+ border-bottom: 1px solid var(--dynamic-border);
1118- background-color: var(--dynamic-surface);1429+ background: color-mix(in srgb, var(--dynamic-bg) 75%, transparent);
1119 }1430 }
1120 1431
1121 .panel-title {1432 .panel-title {
1122 margin: 0;1433 margin: 0;
1123- font-size: 1.3rem;1434+ font-size: 0.95rem;
1124- font-weight: 600;1435+ text-transform: uppercase;
1436+ letter-spacing: 0.08em;
1437+ color: var(--dynamic-text);
1125 }1438 }
1126 1439
1127 .panel-subtitle {1440 .panel-subtitle {
1128 margin: 0;1441 margin: 0;
1129- font-size: 0.95rem;1442+ font-size: 0.72rem;
1130 color: var(--dynamic-muted);1443 color: var(--dynamic-muted);
1444+ letter-spacing: 0.1em;
1445+ text-transform: uppercase;
1131 }1446 }
1132 1447
1133 .panel-body {1448 .panel-body {
1134- padding: clamp(1.25rem, 2vw, 1.75rem) clamp(1.5rem, 2.5vw, 2rem);1449+ padding: 1.1rem 1.1rem 1.25rem;
1135 display: flex;1450 display: flex;
1136 flex-direction: column;1451 flex-direction: column;
1137 gap: 1.1rem;1452 gap: 1.1rem;
@@ -1139,8 +1454,48 @@
1139 overflow: hidden;1454 overflow: hidden;
1140 }1455 }
1141 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+
1142 .history-body {1497 .history-body {
1143- gap: 1rem;1498+ gap: 0.75rem;
1144 display: flex;1499 display: flex;
1145 flex-direction: column;1500 flex-direction: column;
1146 min-height: 0;1501 min-height: 0;
@@ -1150,26 +1505,21 @@
1150 .history-list {1505 .history-list {
1151 display: flex;1506 display: flex;
1152 flex-direction: column;1507 flex-direction: column;
1153- gap: 0.85rem;
1154 flex: 1;1508 flex: 1;
1155 min-height: 0;1509 min-height: 0;
1156 overflow-y: auto;1510 overflow-y: auto;
1157- background-color: var(--dynamic-surface-variant);1511+ border: 1px solid var(--dynamic-border);
1158- border: 2px solid var(--dynamic-border);1512+ background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
1159- padding: 0.5rem;
1160 }1513 }
1161 1514
1162 .history-item {1515 .history-item {
1163- display: flex;1516+ display: grid;
1164- align-items: flex-start;1517+ grid-template-columns: auto 1fr auto;
1518+ align-items: center;
1165 gap: 0.75rem;1519 gap: 0.75rem;
1166- border-bottom: 1px solid var(--dynamic-border);1520+ padding: 0.6rem 0.9rem;
1167- background-color: var(--dynamic-surface-variant);1521+ border-bottom: 1px dashed var(--dynamic-border);
1168- padding: 0.75rem 0.5rem;1522+ transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
1169- cursor: pointer;
1170- transition:
1171- background-color 200ms ease,
1172- border-color 200ms ease;
1173 }1523 }
1174 1524
1175 .history-item:last-child {1525 .history-item:last-child {
@@ -1178,26 +1528,25 @@
1178 1528
1179 .history-item:hover,1529 .history-item:hover,
1180 .history-item:focus-visible {1530 .history-item:focus-visible {
1181- background-color: var(--dynamic-surface);1531+ background: color-mix(in srgb, var(--dynamic-bg) 65%, var(--dynamic-surface) 35%);
1182 outline: none;1532 outline: none;
1183 }1533 }
1184 1534
1185 .history-item.deleting {1535 .history-item.deleting {
1186- opacity: 0.4;1536+ opacity: 0.35;
1187 pointer-events: none;1537 pointer-events: none;
1188- transform: translateX(6px);1538+ transform: translateX(4px);
1189 }1539 }
1190 1540
1191 .history-item-leading {1541 .history-item-leading {
1192- width: 44px;1542+ width: 38px;
1193- height: 44px;1543+ height: 38px;
1194- border: 2px solid var(--dynamic-border);1544+ border: 1px solid var(--dynamic-border);
1195- background-color: var(--dynamic-surface);1545+ background-color: var(--dynamic-bg);
1196 display: flex;1546 display: flex;
1197 align-items: center;1547 align-items: center;
1198 justify-content: center;1548 justify-content: center;
1199 flex-shrink: 0;1549 flex-shrink: 0;
1200- border-radius: 0;
1201 }1550 }
1202 1551
1203 .history-favicon-img {1552 .history-favicon-img {
@@ -1215,21 +1564,22 @@
1215 color: var(--dynamic-muted);1564 color: var(--dynamic-muted);
1216 }1565 }
1217 1566
1567+.history-favicon-fallback svg {
1568+ opacity: 0.55;
1569+}
1570+
1218 .history-item-content {1571 .history-item-content {
1219- flex: 1;
1220- min-width: 0;
1221 display: flex;1572 display: flex;
1222 flex-direction: column;1573 flex-direction: column;
1223- gap: 0.25rem;1574+ gap: 0.2rem;
1224- justify-content: center;1575+ min-width: 0;
1225- overflow: hidden;
1226 }1576 }
1227 1577
1228 .history-item-main {1578 .history-item-main {
1229 display: flex;1579 display: flex;
1230- align-items: flex-start;1580+ align-items: center;
1231 justify-content: space-between;1581 justify-content: space-between;
1232- gap: 1rem;1582+ gap: 0.75rem;
1233 }1583 }
1234 1584
1235 .history-title {1585 .history-title {
@@ -1237,34 +1587,35 @@
1237 color: var(--dynamic-text);1587 color: var(--dynamic-text);
1238 white-space: nowrap;1588 white-space: nowrap;
1239 overflow: hidden;1589 overflow: hidden;
1240- flex: 1;
1241- position: relative;
1242- mask-image: linear-gradient(to right, black 70%, transparent 100%);
1243- -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
1244 }1590 }
1245 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+}
1246 1600
1247 .history-time {1601 .history-time {
1248- font-size: 0.85rem;1602+ font-size: 0.72rem;
1249 color: var(--dynamic-muted);1603 color: var(--dynamic-muted);
1250- flex-shrink: 0;1604+ text-transform: uppercase;
1605+ letter-spacing: 0.08em;
1606+ white-space: nowrap;
1251 }1607 }
1252 1608
1253 .history-url {1609 .history-url {
1254- font-size: 0.8rem;1610+ font-size: 0.72rem;
1255 color: var(--dynamic-muted);1611 color: var(--dynamic-muted);
1256 white-space: nowrap;1612 white-space: nowrap;
1257- font-family:
1258- "JetBrains Mono",
1259- "SFMono-Regular",
1260- Menlo,
1261- monospace;
1262 overflow: hidden;1613 overflow: hidden;
1263- flex: 1;1614+}
1264- min-width: 0;1615+
1265- position: relative;1616+:global(.history-url.truncated) {
1266- mask-image: linear-gradient(to right, black 70%, transparent 100%);1617+ mask-image: linear-gradient(to right, black 85%, transparent 100%);
1267- -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);1618+ -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1268 }1619 }
1269 1620
1270 .history-url :global(.history-url-domain) {1621 .history-url :global(.history-url-domain) {
@@ -1274,364 +1625,318 @@
1274 1625
1275 .history-actions {1626 .history-actions {
1276 display: flex;1627 display: flex;
1277- flex-shrink: 0;1628+ gap: 0.35rem;
1278-}
1279-
1280-.history-pin {
1281- display: flex;
1282- align-items: center;
1283- justify-content: center;
1284- width: 38px;
1285- border-left: 2px solid var(--dynamic-border);
1286- background-color: var(--dynamic-surface);
1287- color: var(--dynamic-muted);
1288- transition: background-color 200ms ease, color 200ms ease;
1289- cursor: pointer;
1290-}
1291-
1292-.history-pin:hover,
1293-.history-pin:focus-visible {
1294- color: var(--dynamic-accent);
1295- background-color: var(--dynamic-bg);
1296- outline: none;
1297 }1629 }
1298 1630
1631+.history-pin,
1299 .history-delete {1632 .history-delete {
1300- display: flex;1633+ display: inline-flex;
1301 align-items: center;1634 align-items: center;
1302 justify-content: center;1635 justify-content: center;
1303- width: 38px;1636+ width: 28px;
1304- border-left: 2px solid var(--dynamic-border);1637+ height: 28px;
1305- background-color: var(--dynamic-surface);1638+ border: 1px solid var(--dynamic-border);
1306 color: var(--dynamic-muted);1639 color: var(--dynamic-muted);
1307- font-size: 1.1rem;1640+ background: transparent;
1308- transition: background-color 200ms ease, color 200ms ease;1641+ transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
1309- flex-shrink: 0;
1310 }1642 }
1311 1643
1644+.history-pin:hover,
1645+.history-pin:focus-visible,
1312 .history-delete:hover,1646 .history-delete:hover,
1313 .history-delete:focus-visible {1647 .history-delete:focus-visible {
1314- color: var(--dynamic-accent);1648+ color: var(--dynamic-text);
1315- background-color: var(--dynamic-bg);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%);
1316 outline: none;1651 outline: none;
1317 }1652 }
1318 1653
1319 .scroll-sentinel {1654 .scroll-sentinel {
1320- width: 100%;
1321 height: 1px;1655 height: 1px;
1322 }1656 }
1323 1657
1324 .loading-more {1658 .loading-more {
1325 padding: 0.75rem;1659 padding: 0.75rem;
1660+ font-size: 0.7rem;
1326 text-align: center;1661 text-align: center;
1327 color: var(--dynamic-muted);1662 color: var(--dynamic-muted);
1328- font-size: 0.85rem;1663+ letter-spacing: 0.08em;
1664+ text-transform: uppercase;
1329 }1665 }
1330 1666
1331 .quick-access-body {1667 .quick-access-body {
1332- gap: 1rem;1668+ gap: 0.9rem;
1333 }1669 }
1334 1670
1335 .quick-access-grid-compact {1671 .quick-access-grid-compact {
1336 display: flex;1672 display: flex;
1337 flex-direction: column;1673 flex-direction: column;
1338- gap: 0.5rem;1674+ gap: 0.75rem;
1339- background-color: var(--dynamic-surface-variant);
1340- border: 2px solid var(--dynamic-border);
1341- padding: 0.5rem;
1342 }1675 }
1343 1676
1344 .quick-access-item-compact {1677 .quick-access-item-compact {
1345- display: flex;1678+ border: 1px solid var(--dynamic-border);
1346- align-items: center;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;
1347 gap: 0.75rem;1683 gap: 0.75rem;
1348- border-bottom: 1px solid var(--dynamic-border);1684+ align-items: center;
1349- background-color: var(--dynamic-surface-variant);1685+ transition: background-color 150ms ease, border-color 150ms ease;
1350- padding: 0.5rem 0.25rem;
1351 cursor: pointer;1686 cursor: pointer;
1352- transition: background-color 200ms ease;
1353-}
1354-
1355-.quick-access-item-compact:last-child {
1356- border-bottom: none;
1357 }1687 }
1358 1688
1359 .quick-access-item-compact:hover,1689 .quick-access-item-compact:hover,
1360 .quick-access-item-compact:focus-visible {1690 .quick-access-item-compact:focus-visible {
1361- background-color: var(--dynamic-surface);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%);
1362 outline: none;1693 outline: none;
1363 }1694 }
1364 1695
1365 .quick-access-favicon-compact {1696 .quick-access-favicon-compact {
1366- width: 32px;1697+ width: 36px;
1367- height: 32px;1698+ height: 36px;
1368- border: 2px solid var(--dynamic-border);1699+ border: 1px solid var(--dynamic-border);
1369- background-color: var(--dynamic-surface-variant);
1370 display: flex;1700 display: flex;
1371 align-items: center;1701 align-items: center;
1372 justify-content: center;1702 justify-content: center;
1373- flex-shrink: 0;1703+ background: var(--dynamic-bg);
1374- border-radius: 0;
1375 }1704 }
1376 1705
1377 .quick-access-favicon-img-compact {1706 .quick-access-favicon-img-compact {
1378- width: 70%;1707+ width: 60%;
1379- height: 70%;1708+ height: 60%;
1380 object-fit: contain;1709 object-fit: contain;
1381 }1710 }
1382 1711
1383 .quick-access-fallback-compact {1712 .quick-access-fallback-compact {
1384- width: 100%;
1385- height: 100%;
1386 display: flex;1713 display: flex;
1387 align-items: center;1714 align-items: center;
1388 justify-content: center;1715 justify-content: center;
1716+ width: 100%;
1717+ height: 100%;
1389 color: var(--dynamic-muted);1718 color: var(--dynamic-muted);
1390 }1719 }
1391 1720
1721+.quick-access-fallback-compact svg {
1722+ opacity: 0.55;
1723+}
1724+
1392 .quick-access-meta-compact {1725 .quick-access-meta-compact {
1393- flex: 1;
1394- min-width: 0;
1395 display: flex;1726 display: flex;
1396 flex-direction: column;1727 flex-direction: column;
1397- gap: 0.125rem;1728+ gap: 0.2rem;
1729+ min-width: 0;
1398 }1730 }
1399 1731
1400 .quick-access-title-compact {1732 .quick-access-title-compact {
1401- font-size: 0.95rem;
1402- font-weight: 600;
1403 color: var(--dynamic-text);1733 color: var(--dynamic-text);
1734+ font-weight: 600;
1404 white-space: nowrap;1735 white-space: nowrap;
1405 overflow: hidden;1736 overflow: hidden;
1406- position: relative;1737+}
1407- mask-image: linear-gradient(to right, black 70%, transparent 100%);1738+
1408- -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);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;
1409 }1746 }
1410 1747
1411 .quick-access-domain-compact {1748 .quick-access-domain-compact {
1412- font-size: 0.8rem;
1413 color: var(--dynamic-muted);1749 color: var(--dynamic-muted);
1414- white-space: nowrap;1750+ font-size: 0.72rem;
1415- overflow: hidden;1751+ text-transform: uppercase;
1416- text-overflow: ellipsis;1752+ letter-spacing: 0.08em;
1417 }1753 }
1418 1754
1419 .quick-access-actions-compact {1755 .quick-access-actions-compact {
1420 display: flex;1756 display: flex;
1421- align-items: center;1757+ flex-direction: column;
1422- gap: 0.5rem;1758+ gap: 0.3rem;
1423- flex-shrink: 0;1759+ align-items: flex-end;
1760+ justify-content: center;
1424 }1761 }
1425 1762
1426 .quick-access-pin-compact {1763 .quick-access-pin-compact {
1427- display: flex;1764+ display: inline-flex;
1428 align-items: center;1765 align-items: center;
1429 justify-content: center;1766 justify-content: center;
1430- width: 24px;1767+ width: 26px;
1431- height: 24px;1768+ height: 26px;
1432 border: 1px solid var(--dynamic-border);1769 border: 1px solid var(--dynamic-border);
1433- background-color: var(--dynamic-surface-variant);
1434 color: var(--dynamic-muted);1770 color: var(--dynamic-muted);
1435- transition: background-color 200ms ease, color 200ms ease;1771+ background: transparent;
1772+ transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
1436 cursor: pointer;1773 cursor: pointer;
1437- border-radius: 0;
1438 }1774 }
1439 1775
1440 .quick-access-pin-compact:hover,1776 .quick-access-pin-compact:hover,
1441 .quick-access-pin-compact:focus-visible {1777 .quick-access-pin-compact:focus-visible {
1442- color: var(--dynamic-accent);1778+ color: var(--dynamic-text);
1443- background-color: var(--dynamic-surface);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%);
1444 outline: none;1781 outline: none;
1445 }1782 }
1446 1783
1447-.quick-access-visits-compact {1784+.quick-access-pin-compact svg {
1448- font-size: 0.75rem;1785+ width: 14px;
1449- color: var(--dynamic-accent);1786+ height: 14px;
1450- text-transform: uppercase;
1451- letter-spacing: 0.05em;
1452- font-weight: 600;
1453- flex-shrink: 0;
1454 }1787 }
1455 1788
1456-1789+.quick-access-visits-compact {
1457-.stat-value {1790+ font-size: 0.7rem;
1458- font-size: 1.4rem;1791+ color: var(--dynamic-muted);
1459- font-weight: 600;1792+ text-transform: uppercase;
1460- color: var(--dynamic-text);1793+ letter-spacing: 0.08em;
1461 }1794 }
1462 1795
1463-.stat-label {1796+.show-more-button {
1464- font-size: 0.8rem;1797+ width: 100%;
1798+ padding: 0.65rem 0.75rem;
1799+ border: 1px solid var(--dynamic-border);
1800+ background: transparent;
1465 color: var(--dynamic-muted);1801 color: var(--dynamic-muted);
1802+ font-size: 0.72rem;
1466 text-transform: uppercase;1803 text-transform: uppercase;
1467 letter-spacing: 0.08em;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;
1468 }1816 }
1469 1817
1470 .stats-grid-horizontal {1818 .stats-grid-horizontal {
1471- display: flex;1819+ display: grid;
1472- flex-wrap: wrap;1820+ gap: 0.85rem;
1473- gap: 1rem;1821+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1474- justify-content: space-between;
1475 }1822 }
1476 1823
1477 .stat-item-horizontal {1824 .stat-item-horizontal {
1478- flex: 1;1825+ border: 1px solid var(--dynamic-border);
1479- min-width: 120px;1826+ padding: 0.85rem;
1480- border: 2px solid var(--dynamic-border);
1481- background-color: var(--dynamic-surface-variant);
1482- padding: 0.75rem;
1483 display: flex;1827 display: flex;
1484 flex-direction: column;1828 flex-direction: column;
1485- gap: 0.25rem;1829+ gap: 0.4rem;
1486- text-align: center;1830+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1487- border-radius: 0;1831+ text-transform: uppercase;
1832+ letter-spacing: 0.08em;
1488 }1833 }
1489 1834
1490-.shortcuts-panel {1835+.stat-value {
1491- gap: 0;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);
1492 }1844 }
1493 1845
1494 .shortcuts-list {1846 .shortcuts-list {
1495- display: flex;1847+ display: grid;
1496- flex-wrap: wrap;1848+ grid-template-columns: repeat(2, 1fr);
1497- gap: 0.85rem;1849+ gap: 0.6rem;
1498 }1850 }
1499 1851
1500 .shortcut-item {1852 .shortcut-item {
1501- display: flex;1853+ display: grid;
1854+ grid-template-columns: auto 1fr;
1855+ gap: 0.6rem;
1502 align-items: center;1856 align-items: center;
1503- gap: 0.75rem;1857+ border: 1px solid var(--dynamic-border);
1504- border: 2px solid var(--dynamic-border);1858+ padding: 0.6rem 0.75rem;
1505- background-color: var(--dynamic-bg);1859+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1506- padding: 0.75rem 1rem;
1507- border-radius: 0;
1508 }1860 }
1509 1861
1510 .shortcut-key-badge {1862 .shortcut-key-badge {
1511- font-family:1863+ font-size: 0.72rem;
1512- "JetBrains Mono",
1513- "SFMono-Regular",
1514- Menlo,
1515- monospace;
1516- font-size: 0.85rem;
1517 text-transform: uppercase;1864 text-transform: uppercase;
1518 letter-spacing: 0.08em;1865 letter-spacing: 0.08em;
1519- padding: 0.35rem 0.65rem;1866+ padding: 0.25rem 0.6rem;
1520- border: 2px solid var(--dynamic-border);1867+ border: 1px solid var(--dynamic-border);
1521- background-color: var(--dynamic-surface-variant);
1522- color: var(--dynamic-muted);
1523- font-weight: 500;
1524- border-radius: 0;
1525-}
1526-
1527-.shortcut-desc {
1528- font-size: 0.95rem;
1529 color: var(--dynamic-text);1868 color: var(--dynamic-text);
1869+ background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
1530 }1870 }
1531 1871
1532-.shortcuts-panel .loading {1872+.shortcut-desc {
1533- border-style: solid;1873+ font-size: 0.72rem;
1874+ color: var(--dynamic-muted);
1875+ letter-spacing: 0.06em;
1534 }1876 }
1535 1877
1536-/* Responsive Design */1878+@media (max-width: 960px) {
1537-@media (max-width: 1200px) {1879+ .terminal-body {
1538- .history-panel,1880+ padding: 1.1rem;
1539- .quick-access-panel {
1540- min-width: min(28rem, 100%);
1541 }1881 }
1542-}
1543 1882
1544-@media (max-width: 960px) {1883+ .terminal-grid {
1545- .homepage-shell {1884+ gap: 0.75rem;
1546- padding: clamp(1.25rem, 5vw, 2rem);
1547 }1885 }
1548 1886
1549 .main-panels {1887 .main-panels {
1550- flex-direction: column;1888+ grid-template-columns: 1fr;
1551 }1889 }
1552 1890
1553- .history-panel,1891+ .last-visit-banner {
1554- .quick-access-panel {1892+ grid-template-columns: minmax(0, auto) minmax(0, 1fr);
1555- min-width: 100%;1893+ grid-template-rows: repeat(3, auto);
1556- max-width: 100%;1894+ gap: 0.5rem 0.75rem;
1557- width: 100%;
1558 }1895 }
1559 1896
1897+ .banner-time,
1898+ .banner-absolute {
1899+ justify-self: flex-start;
1900+ }
1560 }1901 }
1561 1902
1562 @media (max-width: 640px) {1903 @media (max-width: 640px) {
1563- .theme-toggle-button {1904+ .homepage-shell {
1564- width: 2.75rem;1905+ padding: 1.25rem;
1565- height: 2.75rem;
1566- }
1567-
1568- .panel-body,
1569- .panel-header {
1570- padding-left: 1.25rem;
1571- padding-right: 1.25rem;
1572 }1906 }
1573 1907
1574-1908+ .terminal-header {
1575- .history-item {
1576 flex-direction: column;1909 flex-direction: column;
1577 align-items: flex-start;1910 align-items: flex-start;
1578 }1911 }
1579 1912
1580- .history-actions {1913+ .theme-toggle-button {
1581- width: 100%;1914+ align-self: flex-end;
1582- border-top: 2px solid var(--dynamic-border);
1583 }1915 }
1584 1916
1585- .history-pin,1917+ .terminal-status-row {
1586- .history-delete {1918+ gap: 0.5rem;
1587- flex: 1;
1588- border-left: 0;
1589 }1919 }
1590 1920
1591- .history-pin {1921+ .status-chip {
1592- border-right: 1px solid var(--dynamic-border);1922+ padding: 0.35rem 0.65rem;
1593 }1923 }
1594 1924
1595- .history-item-main {1925+ .last-visit-banner {
1596- flex-direction: column;1926+ grid-template-columns: minmax(0, 1fr);
1597- align-items: flex-start;1927+ grid-template-rows: repeat(4, auto);
1598- gap: 0.35rem;
1599 }1928 }
1600 1929
1601- .history-time {1930+ .banner-label,
1602- order: 3;1931+ .banner-title,
1603- }1932+ .banner-domain,
1604-1933+ .banner-time,
1605- .stats-grid-horizontal {1934+ .banner-absolute {
1606- flex-direction: column;1935+ justify-self: flex-start;
1607- gap: 0.75rem;
1608 }1936 }
1609 1937
1610- .stat-item-horizontal {1938+ .shortcuts-list {
1611- min-width: 100%;1939+ grid-template-columns: 1fr;
1612 }1940 }
1613 }1941 }
1614-
1615-.show-more-button {
1616- width: 100%;
1617- padding: 0.75rem 1rem;
1618- border: 2px solid var(--dynamic-border);
1619- background-color: var(--dynamic-surface-variant);
1620- color: var(--dynamic-text);
1621- font-size: 0.9rem;
1622- font-weight: 500;
1623- cursor: pointer;
1624- transition:
1625- background-color 200ms ease,
1626- border-color 200ms ease;
1627- border-radius: 0;
1628- margin-top: 0.5rem;
1629-}
1630-
1631-.show-more-button:hover,
1632-.show-more-button:focus-visible {
1633- background-color: var(--dynamic-surface);
1634- border-color: var(--dynamic-accent);
1635- outline: none;
1636-}
1637 </style>1942 </style>
M gui/src/services/app.ts +41 -50
@@ -79,57 +79,48 @@ export class AppService {
79 }79 }
80 80
81 async loadShortcuts(): Promise<void> {81 async loadShortcuts(): Promise<void> {
82- try {82+ return new Promise((resolve, reject) => {
83- const cfg = await fetch("/api/config").then((r) => r.json());83+ // Set up response handler
84- const raw = cfg?.search_shortcuts || {};84+ window.__dumber_search_shortcuts = (data: unknown) => {
85- // Normalize field casing from backend (supports URL/Description and url/description)85+ // Type guard
86- const normalized: Record<string, SearchShortcut> = {};86+ if (typeof data !== "object" || data === null) {
87- for (const [key, value] of Object.entries(raw)) {87+ reject(new Error("Invalid shortcuts data"));
88- const v = value as Record<string, unknown>;88+ return;
89- normalized[key] = {89+ }
90- id: 0,90+ const dataObj = data as Record<string, unknown>;
91- shortcut: key,91+ // Normalize field casing from backend (supports URL/Description and url/description)
92- url_template: (v.url ?? v.URL ?? "") as string,92+ const normalized: Record<string, SearchShortcut> = {};
93- description: (v.description ?? v.Description ?? "") as string,93+ for (const [key, value] of Object.entries(dataObj)) {
94- created_at: null,94+ const v = value as Record<string, unknown>;
95- };95+ normalized[key] = {
96- }96+ id: 0,
97- this.shortcuts = normalized;97+ shortcut: key,
98- } catch (error) {98+ url_template: (v.url ?? v.URL ?? "") as string,
99- console.error("Failed to load shortcuts:", error);99+ description: (v.description ?? v.Description ?? "") as string,
100- // Mock shortcuts for development/fallback100+ created_at: null,
101- this.shortcuts = {101+ };
102- g: {102+ }
103- id: 0,103+ this.shortcuts = normalized;
104- shortcut: "g",104+ resolve();
105- url_template: "https://google.com/search?q={query}",
106- description: "Google Search",
107- created_at: null,
108- },
109- gh: {
110- id: 0,
111- shortcut: "gh",
112- url_template: "https://github.com/search?q={query}",
113- description: "GitHub Search",
114- created_at: null,
115- },
116- so: {
117- id: 0,
118- shortcut: "so",
119- url_template: "https://stackoverflow.com/search?q={query}",
120- description: "Stack Overflow",
121- created_at: null,
122- },
123- w: {
124- id: 0,
125- shortcut: "w",
126- url_template:
127- "https://en.wikipedia.org/wiki/Special:Search?search={query}",
128- description: "Wikipedia",
129- created_at: null,
130- },
131 };105 };
132- }106+
107+ window.__dumber_search_shortcuts_error = (error: string) => {
108+ console.error("Failed to load shortcuts:", error);
109+ reject(new Error(error));
110+ };
111+
112+ // Send message to Go backend
113+ const bridge = window.webkit?.messageHandlers?.dumber;
114+ if (bridge && typeof bridge.postMessage === "function") {
115+ bridge.postMessage(
116+ JSON.stringify({
117+ type: "get_search_shortcuts",
118+ }),
119+ );
120+ } else {
121+ reject(new Error("WebKit message handler not available"));
122+ }
123+ });
133 }124 }
134 125
135 getHistory(): HistoryEntry[] {126 getHistory(): HistoryEntry[] {
M gui/src/styles/tailwind.css +9 -59
@@ -1,17 +1,5 @@
1 @import "tailwindcss";1 @import "tailwindcss";
2 2
3-/* Define custom theme colors for v4 */
4-@theme {
5- /* Light theme colors (default) */
6- --color-browser-bg: rgb(253 252 250);
7- --color-browser-surface: rgb(245 245 240);
8- --color-browser-surface-variant: rgb(253 250 245);
9- --color-browser-text: rgb(17 24 39);
10- --color-browser-muted: rgb(107 114 128);
11- --color-browser-accent: rgb(59 130 246);
12- --color-browser-border: rgb(229 231 235);
13-}
14-
15 /* Dynamic theme switching via CSS variables */3 /* Dynamic theme switching via CSS variables */
16 :root {4 :root {
17 --dynamic-bg: var(--color-browser-bg);5 --dynamic-bg: var(--color-browser-bg);
@@ -23,17 +11,6 @@
23 --dynamic-border: var(--color-browser-border);11 --dynamic-border: var(--color-browser-border);
24 }12 }
25 13
26-/* Dark theme colors (applied via .dark class from GTK) */
27-.dark {
28- --dynamic-bg: rgb(17 24 39);
29- --dynamic-surface-variant: rgb(21 30 50);
30- --dynamic-surface: rgb(31 41 55);
31- --dynamic-text: rgb(243 244 246);
32- --dynamic-muted: rgb(156 163 175);
33- --dynamic-accent: rgb(96 165 250);
34- --dynamic-border: rgb(55 65 81);
35-}
36-
37 /* Base styles for browser UI components */14 /* Base styles for browser UI components */
38 .browser-ui {15 .browser-ui {
39 background-color: var(--dynamic-bg);16 background-color: var(--dynamic-bg);
@@ -44,7 +21,7 @@
44 21
45 /* Toast notification styles */22 /* Toast notification styles */
46 .toast-base {23 .toast-base {
47- border-radius: 0.375rem; /* rounded-md */24+ border-radius: 0;
48 box-shadow:25 box-shadow:
49 0 10px 15px -3px rgb(0 0 0 / 0.1),26 0 10px 15px -3px rgb(0 0 0 / 0.1),
50 0 4px 6px -4px rgb(0 0 0 / 0.1);27 0 4px 6px -4px rgb(0 0 0 / 0.1);
@@ -56,47 +33,19 @@
56 border: 1px solid var(--dynamic-border);33 border: 1px solid var(--dynamic-border);
57 }34 }
58 35
59-.toast-success {36+.toast-success,
60- background-color: rgb(240 253 244);37+.toast-error,
61- color: rgb(22 163 74);
62- border: 1px solid rgb(187 247 208);
63-}
64-
65-.dark .toast-success {
66- background-color: rgb(22 163 74 / 0.2);
67- color: rgb(187 247 208);
68- border-color: rgb(22 163 74);
69-}
70-
71-.toast-error {
72- background-color: rgb(254 242 242);
73- color: rgb(220 38 38);
74- border: 1px solid rgb(254 202 202);
75-}
76-
77-.dark .toast-error {
78- background-color: rgb(220 38 38 / 0.2);
79- color: rgb(254 202 202);
80- border-color: rgb(220 38 38);
81-}
82-
83 .toast-info {38 .toast-info {
84- background-color: rgb(239 246 255);39+ background-color: var(--toast-bg, var(--dynamic-surface));
85- color: rgb(59 130 246);40+ color: var(--toast-text, var(--dynamic-text));
86- border: 1px solid rgb(191 219 254);41+ border: 1px solid var(--toast-border, var(--dynamic-border));
87-}
88-
89-.dark .toast-info {
90- background-color: rgb(59 130 246 / 0.2);
91- color: rgb(191 219 254);
92- border-color: rgb(59 130 246);
93 }42 }
94 43
95 /* Omnibox/Find bar styles */44 /* Omnibox/Find bar styles */
96 .omnibox-base {45 .omnibox-base {
97 background-color: var(--dynamic-bg);46 background-color: var(--dynamic-bg);
98 color: var(--dynamic-text);47 color: var(--dynamic-text);
99- border: 2px solid var(--dynamic-border);48+ border: 1px solid var(--dynamic-border);
100 box-shadow:49 box-shadow:
101 0 4px 6px -1px rgb(0 0 0 / 0.1),50 0 4px 6px -1px rgb(0 0 0 / 0.1),
102 0 2px 4px -2px rgb(0 0 0 / 0.1);51 0 2px 4px -2px rgb(0 0 0 / 0.1);
@@ -123,7 +72,7 @@
123 .omnibox-results {72 .omnibox-results {
124 background-color: var(--dynamic-bg);73 background-color: var(--dynamic-bg);
125 border: 1px solid var(--dynamic-border);74 border: 1px solid var(--dynamic-border);
126- border-radius: 0.375rem;75+ border-radius: 0;
127 box-shadow:76 box-shadow:
128 0 10px 15px -3px rgb(0 0 0 / 0.1),77 0 10px 15px -3px rgb(0 0 0 / 0.1),
129 0 4px 6px -4px rgb(0 0 0 / 0.1);78 0 4px 6px -4px rgb(0 0 0 / 0.1);
@@ -171,6 +120,7 @@
171 120
172 .findbar-button {121 .findbar-button {
173 padding: 0.25rem 0.5rem;122 padding: 0.25rem 0.5rem;
123+ border-radius: 0;
174 color: var(--dynamic-muted);124 color: var(--dynamic-muted);
175 transition: colors 150ms;125 transition: colors 150ms;
176 font-size: 0.875rem;126 font-size: 0.875rem;
M internal/app/browser/stacked_panes.go +157 -37
@@ -1,23 +1,34 @@
1 package browser1 package browser
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "fmt"6 "fmt"
6 "log"7 "log"
8+ "sync/atomic"
7 "time"9 "time"
8 10
11+ "github.com/bnema/dumber/internal/cache"
9 "github.com/bnema/dumber/pkg/webkit"12 "github.com/bnema/dumber/pkg/webkit"
10 )13 )
11 14
15+const (
16+ // faviconPlaceholder is the emoji displayed when no favicon is available
17+ faviconPlaceholder = "🌐"
18+)
19+
12 // StackedPaneManager handles all stacked pane operations20 // StackedPaneManager handles all stacked pane operations
13 type StackedPaneManager struct {21 type StackedPaneManager struct {
14- wm *WorkspaceManager22+ wm *WorkspaceManager
23+ titleBarToPane map[uintptr]*paneNode
24+ nextTitleBarID uint64
15 }25 }
16 26
17 // NewStackedPaneManager creates a new stacked pane manager27 // NewStackedPaneManager creates a new stacked pane manager
18 func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {28 func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {
19 return &StackedPaneManager{29 return &StackedPaneManager{
20- wm: wm,30+ wm: wm,
31+ titleBarToPane: make(map[uintptr]*paneNode),
21 }32 }
22 }33 }
23 34
@@ -409,6 +420,15 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
409 }420 }
410 if pane.titleBar != 0 {421 if pane.titleBar != 0 {
411 webkit.WidgetSetVisible(pane.titleBar, true)422 webkit.WidgetSetVisible(pane.titleBar, true)
423+
424+ // Refresh title bar to pick up any newly downloaded favicons
425+ if pane.pane != nil && pane.pane.webView != nil {
426+ title := pane.pane.webView.GetTitle()
427+ if title == "" {
428+ title = "New Tab"
429+ }
430+ spm.updateTitleBarLabel(pane, title)
431+ }
412 }432 }
413 }433 }
414 }434 }
@@ -494,43 +514,31 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
494 514
495 // createTitleBar creates a title bar widget for a pane in a stack515 // createTitleBar creates a title bar widget for a pane in a stack
496 func (spm *StackedPaneManager) createTitleBar(pane *paneNode) uintptr {516 func (spm *StackedPaneManager) createTitleBar(pane *paneNode) uintptr {
497- titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)517+ // Get the actual title and URL from the WebView
498- if titleBox == 0 {518+ var titleText, pageURL string
499- log.Printf("[workspace] failed to create title box")
500- return 0
501- }
502-
503- webkit.WidgetAddCSSClass(titleBox, "stacked-pane-title")
504- webkit.WidgetSetHExpand(titleBox, true)
505- webkit.WidgetSetVExpand(titleBox, false)
506-
507- // Get the actual title from the WebView instead of hardcoding "New Tab"
508- var titleText string
509 if pane.pane != nil && pane.pane.webView != nil {519 if pane.pane != nil && pane.pane.webView != nil {
510 titleText = pane.pane.webView.GetTitle()520 titleText = pane.pane.webView.GetTitle()
511 if titleText == "" {521 if titleText == "" {
512 titleText = "New Tab" // Fallback only when title is actually empty522 titleText = "New Tab" // Fallback only when title is actually empty
513 }523 }
524+ pageURL = pane.pane.webView.GetCurrentURL()
514 } else {525 } else {
515 titleText = "New Tab"526 titleText = "New Tab"
516 }527 }
517 528
518- // Create title label with the actual title529+ titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
519- titleLabel := webkit.NewLabel(titleText)530+ if titleBar != 0 {
520- if titleLabel == 0 {531+ // Store the mapping from titleBar ID to pane
521- log.Printf("[workspace] failed to create title label")532+ titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
522- return titleBox533+ spm.titleBarToPane[uintptr(titleBarID)] = pane
523- }
524 534
525- webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")535+ // Attach click handler
526- webkit.LabelSetMaxWidthChars(titleLabel, 50)536+ webkit.WidgetAttachClickHandler(titleBar, func() {
527- webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)537+ spm.handleTitleBarClick(uintptr(titleBarID))
528-538+ })
529- webkit.BoxAppend(titleBox, titleLabel)539+ }
530- webkit.WidgetShow(titleBox)
531- webkit.WidgetShow(titleLabel)
532 540
533- return titleBox541+ return titleBar
534 }542 }
535 543
536 // UpdateTitleBar updates the title bar label for a WebView in stacked panes544 // UpdateTitleBar updates the title bar label for a WebView in stacked panes
@@ -578,19 +586,28 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
578 return586 return
579 }587 }
580 588
581- // Format the title for display589+ // Get the current URL from the WebView for favicon
582- displayTitle := title590+ var pageURL string
583- if len(displayTitle) > 50 {591+ if node.pane != nil && node.pane.webView != nil {
584- displayTitle = displayTitle[:47] + "..."592+ pageURL = node.pane.webView.GetCurrentURL()
585 }593 }
586 594
587- // Create new title bar with updated title595+ // Create new title bar with updated title and favicon
588- newTitleBar := spm.createTitleBarWithTitle(displayTitle)596+ newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
589 if newTitleBar == 0 {597 if newTitleBar == 0 {
590 log.Printf("[workspace] failed to create new title bar")598 log.Printf("[workspace] failed to create new title bar")
591 return599 return
592 }600 }
593 601
602+ // Store the mapping from titleBar ID to pane and attach click handler
603+ titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
604+ spm.titleBarToPane[uintptr(titleBarID)] = node
605+
606+ // Attach click handler
607+ webkit.WidgetAttachClickHandler(newTitleBar, func() {
608+ spm.handleTitleBarClick(uintptr(titleBarID))
609+ })
610+
594 // Replace the old title bar in the stack611 // Replace the old title bar in the stack
595 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != 0 {612 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != 0 {
596 // Find the correct insertion position based on the pane's index in the stack613 // Find the correct insertion position based on the pane's index in the stack
@@ -628,8 +645,8 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
628 }645 }
629 }646 }
630 647
631-// createTitleBarWithTitle creates a title bar with a specific title648+// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
632-func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr {649+func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) uintptr {
633 titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)650 titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)
634 if titleBox == 0 {651 if titleBox == 0 {
635 return 0652 return 0
@@ -639,6 +656,75 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr {
639 webkit.WidgetSetHExpand(titleBox, true)656 webkit.WidgetSetHExpand(titleBox, true)
640 webkit.WidgetSetVExpand(titleBox, false)657 webkit.WidgetSetVExpand(titleBox, false)
641 658
659+ // Add favicon placeholder for debugging (always visible)
660+ var faviconImg uintptr
661+ var debugReason string
662+
663+ if pageURL == "" {
664+ debugReason = "empty pageURL"
665+ } else if spm.wm == nil {
666+ debugReason = "nil workspace manager"
667+ } else if spm.wm.app == nil {
668+ debugReason = "nil app"
669+ } else if spm.wm.app.queries == nil {
670+ debugReason = "nil queries"
671+ } else {
672+ // Query database for favicon URL
673+ ctx := context.Background()
674+ entry, err := spm.wm.app.queries.GetHistoryEntry(ctx, pageURL)
675+ if err != nil {
676+ debugReason = fmt.Sprintf("DB query failed: %v", err)
677+ log.Printf("[favicon] Failed to get history entry for %s: %v", pageURL, err)
678+ } else if !entry.FaviconUrl.Valid || entry.FaviconUrl.String == "" {
679+ debugReason = "no favicon URL in DB"
680+ log.Printf("[favicon] No favicon URL in DB for %s", pageURL)
681+ } else {
682+ faviconURL := entry.FaviconUrl.String
683+ log.Printf("[favicon] Found favicon URL in DB for %s: %s", pageURL, faviconURL)
684+
685+ // Use favicon cache with favicon URL (same as dmenu)
686+ faviconCache, err := cache.NewFaviconCache()
687+ if err != nil {
688+ debugReason = fmt.Sprintf("cache init failed: %v", err)
689+ log.Printf("[favicon] Failed to create cache: %v", err)
690+ } else {
691+ faviconPath := faviconCache.GetCachedPath(faviconURL)
692+ if faviconPath != "" {
693+ log.Printf("[favicon] Loading cached favicon from: %s", faviconPath)
694+ faviconImg = webkit.ImageNewFromFile(faviconPath)
695+ if faviconImg != 0 {
696+ webkit.ImageSetPixelSize(faviconImg, 16)
697+ webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
698+ log.Printf("[favicon] Successfully created favicon image widget")
699+ } else {
700+ debugReason = "image widget creation failed"
701+ log.Printf("[favicon] Failed to create image widget from %s", faviconPath)
702+ }
703+ } else {
704+ debugReason = "not cached yet"
705+ log.Printf("[favicon] Favicon not cached yet, starting download: %s", faviconURL)
706+ // Start async download for next time (same as dmenu)
707+ faviconCache.CacheAsync(faviconURL)
708+ }
709+ }
710+ }
711+ }
712+
713+ // Add favicon or placeholder
714+ if faviconImg != 0 {
715+ webkit.BoxAppend(titleBox, faviconImg)
716+ webkit.WidgetShow(faviconImg)
717+ } else {
718+ // DEBUG: Add visible placeholder label to show code is running
719+ placeholderLabel := webkit.NewLabel(faviconPlaceholder)
720+ if placeholderLabel != 0 {
721+ webkit.WidgetAddCSSClass(placeholderLabel, "stacked-pane-favicon-placeholder")
722+ webkit.BoxAppend(titleBox, placeholderLabel)
723+ webkit.WidgetShow(placeholderLabel)
724+ log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason)
725+ }
726+ }
727+
642 // Create title label with the specified title728 // Create title label with the specified title
643 titleLabel := webkit.NewLabel(title)729 titleLabel := webkit.NewLabel(title)
644 if titleLabel == 0 {730 if titleLabel == 0 {
@@ -646,7 +732,6 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr {
646 }732 }
647 733
648 webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")734 webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")
649- webkit.LabelSetMaxWidthChars(titleLabel, 50)
650 webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)735 webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)
651 736
652 webkit.BoxAppend(titleBox, titleLabel)737 webkit.BoxAppend(titleBox, titleLabel)
@@ -852,3 +937,38 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
852 return nil937 return nil
853 }938 }
854 }939 }
940+
941+// handleTitleBarClick handles clicks on title bars to switch the active pane
942+func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uintptr) {
943+ pane, exists := spm.titleBarToPane[titleBarID]
944+ if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
945+ return
946+ }
947+
948+ stackNode := pane.parent
949+
950+ // Find the index of the clicked pane
951+ for i, p := range stackNode.stackedPanes {
952+ if p == pane {
953+ // Only switch if it's not already active
954+ if i != stackNode.activeStackIndex {
955+ log.Printf("[workspace] title bar clicked: switching from pane %d to pane %d",
956+ stackNode.activeStackIndex, i)
957+
958+ // Update title bar for the pane transitioning from ACTIVE to INACTIVE
959+ currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
960+ if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != 0 {
961+ currentTitle := currentActivePane.pane.webView.GetTitle()
962+ if currentTitle != "" {
963+ spm.updateTitleBarLabel(currentActivePane, currentTitle)
964+ }
965+ }
966+
967+ stackNode.activeStackIndex = i
968+ spm.UpdateStackVisibility(stackNode)
969+ spm.wm.SetActivePane(pane, SourceStackNav)
970+ }
971+ return
972+ }
973+ }
974+}
M internal/app/browser/webview.go +30 -1
@@ -45,6 +45,25 @@ func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {
45 return mc45 return mc
46 }46 }
47 47
48+func buildColorPalettes(cfg config.AppearanceConfig) webkit.ColorPalettes {
49+ toPalette := func(src config.ColorPalette) webkit.ColorPalette {
50+ return webkit.ColorPalette{
51+ Background: src.Background,
52+ Surface: src.Surface,
53+ SurfaceVariant: src.SurfaceVariant,
54+ Text: src.Text,
55+ Muted: src.Muted,
56+ Accent: src.Accent,
57+ Border: src.Border,
58+ }
59+ }
60+
61+ return webkit.ColorPalettes{
62+ Light: toPalette(cfg.LightPalette),
63+ Dark: toPalette(cfg.DarkPalette),
64+ }
65+}
66+
48 func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {67 func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
49 dataDir, err := config.GetDataDir()68 dataDir, err := config.GetDataDir()
50 if err != nil {69 if err != nil {
@@ -67,7 +86,7 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
67 cfg := &webkit.Config{86 cfg := &webkit.Config{
68 Assets: app.assets,87 Assets: app.assets,
69 InitialURL: "dumb://homepage",88 InitialURL: "dumb://homepage",
70- ZoomDefault: 1.0,89+ ZoomDefault: app.config.DefaultZoom,
71 EnableDeveloperExtras: true,90 EnableDeveloperExtras: true,
72 DataDir: webkitData,91 DataDir: webkitData,
73 CacheDir: webkitCache,92 CacheDir: webkitCache,
@@ -93,6 +112,7 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
93 CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,112 CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
94 DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,113 DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
95 },114 },
115+ Colors: buildColorPalettes(app.config.Appearance),
96 }116 }
97 117
98 return cfg, nil118 return cfg, nil
@@ -124,6 +144,15 @@ func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
124 app.workspace.RegisterNavigationHandler(view)144 app.workspace.RegisterNavigationHandler(view)
125 }145 }
126 146
147+ // Register favicon handlers for this webview (needed for each pane)
148+ // This ensures favicons are detected and cached for all panes, not just the main webview
149+ view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {
150+ // Call the private handler through a public wrapper or directly via browserService
151+ // We can't call handleFaviconURIChanged directly as it's private, so we need to expose it
152+ // For now, duplicate the minimal logic here
153+ app.browserService.ProcessFaviconURI(pageURI, faviconURI)
154+ })
155+
127 return pane, nil156 return pane, nil
128 }157 }
129 158
M internal/app/browser/workspace_css.go +4 -0
@@ -117,6 +117,10 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
117 font-weight: 500;117 font-weight: 500;
118 }118 }
119 119
120+ .stacked-pane-favicon {
121+ margin-right: 4px;
122+ }
123+
120 .stacked-pane-active {124 .stacked-pane-active {
121 /* Active pane is fully visible */125 /* Active pane is fully visible */
122 }126 }
M internal/app/browser/workspace_pane_ops.go +1 -15
@@ -124,21 +124,7 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
124 log.Printf("[workspace] failed to prepare blank pane: %v", err)124 log.Printf("[workspace] failed to prepare blank pane: %v", err)
125 }125 }
126 126
127- // Wait for about:blank to load before opening omnibox127+ // Omnibox will auto-open on about:blank via client-side detection
128- target.pane.webView.RegisterURIChangedHandler(func(uri string) {
129- if uri == blankURL {
130- // Defer omnibox opening to allow page to fully initialize
131- wm.scheduleIdleGuarded(func() bool {
132- if target == nil || target.pane == nil || target.pane.webView == nil {
133- return false
134- }
135- if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
136- log.Printf("[workspace] failed to open omnibox: %v", injErr)
137- }
138- return false // Remove idle callback
139- }, target)
140- }
141- })
142 }128 }
143 129
144 // safelyDetachControllersBeforeReparent removes GTK controllers that GTK will auto-clean up during reparenting.130 // safelyDetachControllersBeforeReparent removes GTK controllers that GTK will auto-clean up during reparenting.
M internal/app/messaging/handler.go +74 -39
@@ -4,8 +4,6 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "log"6 "log"
7- neturl "net/url"
8- "sort"
9 "strconv"7 "strconv"
10 "strings"8 "strings"
11 "time"9 "time"
@@ -133,6 +131,10 @@ func (h *Handler) Handle(payload string) {
133 h.handleConsoleMessage(msg)131 h.handleConsoleMessage(msg)
134 case "request-webview-id":132 case "request-webview-id":
135 h.handleWebViewIDRequest(msg)133 h.handleWebViewIDRequest(msg)
134+ case "get_search_shortcuts":
135+ h.handleGetSearchShortcuts(msg)
136+ case "get_color_palettes":
137+ h.handleGetColorPalettes(msg)
136 }138 }
137 }139 }
138 140
@@ -273,45 +275,10 @@ func (h *Handler) handleQuery(msg Message) {
273 return scheme + "://" + host + "/favicon.ico"275 return scheme + "://" + host + "/favicon.ico"
274 }276 }
275 277
276- expandShortcut := func(tpl string, query string) string {
277- esc := neturl.QueryEscape(query)
278- u := strings.ReplaceAll(tpl, "{query}", esc)
279- u = strings.ReplaceAll(u, "%s", esc)
280- return u
281- }
282-
283 results := make([]suggestion, 0, limit)278 results := make([]suggestion, 0, limit)
284 seen := make(map[string]struct{}, limit*2)279 seen := make(map[string]struct{}, limit*2)
285 280
286- // Shortcuts281+ // Shortcuts intentionally omitted; rely on explicit prefix commands (e.g., gh:) or history results
287- if shortcuts, err := h.browserService.GetSearchShortcuts(ctx); err != nil {
288- log.Printf("[ERROR] Failed to get search shortcuts: %v", err)
289- } else if len(shortcuts) > 0 {
290- log.Printf("[DEBUG] Found %d search shortcuts", len(shortcuts))
291- keys := make([]string, 0, len(shortcuts))
292- for k := range shortcuts {
293- keys = append(keys, k)
294- }
295- sort.Strings(keys)
296- for _, k := range keys {
297- sc := shortcuts[k]
298- url := expandShortcut(sc.URL, q)
299- log.Printf("[DEBUG] Shortcut %s -> %s", k, url)
300- if url == "" {
301- continue
302- }
303- if _, ok := seen[url]; ok {
304- continue
305- }
306- results = append(results, suggestion{URL: url, Favicon: buildFavicon(url)})
307- seen[url] = struct{}{}
308- if len(results) >= limit {
309- break
310- }
311- }
312- } else {
313- log.Printf("[DEBUG] No search shortcuts found")
314- }
315 282
316 // History283 // History
317 if len(results) < limit {284 if len(results) < limit {
@@ -339,7 +306,14 @@ func (h *Handler) handleQuery(msg Message) {
339 if _, ok := seen[url]; ok {306 if _, ok := seen[url]; ok {
340 continue307 continue
341 }308 }
342- results = append(results, suggestion{URL: url, Favicon: buildFavicon(url)})309+ // Use favicon_url from database if available, otherwise build it
310+ favicon := ""
311+ if s, ok := m["favicon_url"].(string); ok && s != "" {
312+ favicon = s
313+ } else {
314+ favicon = buildFavicon(url)
315+ }
316+ results = append(results, suggestion{URL: url, Favicon: favicon})
343 seen[url] = struct{}{}317 seen[url] = struct{}{}
344 if len(results) >= limit {318 if len(results) >= limit {
345 break319 break
@@ -631,3 +605,64 @@ func (h *Handler) handleWebViewIDRequest(msg Message) {
631 log.Printf("[messaging] Failed to send webview ID: %v", err)605 log.Printf("[messaging] Failed to send webview ID: %v", err)
632 }606 }
633 }607 }
608+
609+// handleGetSearchShortcuts sends search shortcuts configuration to JavaScript
610+func (h *Handler) handleGetSearchShortcuts(msg Message) {
611+ if h.webView == nil {
612+ log.Printf("[messaging] Cannot provide search shortcuts - no webview available")
613+ return
614+ }
615+
616+ if h.webView.IsDestroyed() {
617+ log.Printf("[messaging] Cannot provide search shortcuts - webview is destroyed")
618+ return
619+ }
620+
621+ // Get search shortcuts from browser service config
622+ shortcuts, err := h.browserService.GetSearchShortcuts(context.Background())
623+ if err != nil {
624+ log.Printf("[messaging] Failed to get search shortcuts: %v", err)
625+ _ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to get search shortcuts')")
626+ return
627+ }
628+
629+ // Marshal to JSON
630+ b, err := json.Marshal(shortcuts)
631+ if err != nil {
632+ log.Printf("[messaging] Failed to marshal search shortcuts: %v", err)
633+ _ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to load search shortcuts')")
634+ return
635+ }
636+
637+ // Inject the search shortcuts into the page
638+ log.Printf("[messaging] Sending search shortcuts to JavaScript")
639+ _ = h.webView.InjectScript("window.__dumber_search_shortcuts && window.__dumber_search_shortcuts(" + string(b) + ")")
640+}
641+
642+// handleGetColorPalettes sends color palettes configuration to JavaScript
643+func (h *Handler) handleGetColorPalettes(msg Message) {
644+ if h.webView == nil {
645+ log.Printf("[messaging] Cannot provide color palettes - no webview available")
646+ return
647+ }
648+
649+ if h.webView.IsDestroyed() {
650+ log.Printf("[messaging] Cannot provide color palettes - webview is destroyed")
651+ return
652+ }
653+
654+ // Get color palettes from browser service config
655+ palettes := h.browserService.GetColorPalettesForMessaging()
656+
657+ // Marshal to JSON
658+ b, err := json.Marshal(palettes)
659+ if err != nil {
660+ log.Printf("[messaging] Failed to marshal color palettes: %v", err)
661+ _ = h.webView.InjectScript("window.__dumber_color_palettes_error && window.__dumber_color_palettes_error('Failed to load color palettes')")
662+ return
663+ }
664+
665+ // Inject the color palettes into the page
666+ log.Printf("[messaging] Sending color palettes to JavaScript")
667+ _ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")
668+}
M internal/cache/favicon.go +8 -3
@@ -13,6 +13,7 @@ import (
13 "net/http"13 "net/http"
14 "os"14 "os"
15 "path/filepath"15 "path/filepath"
16+ "strings"
16 "time"17 "time"
17 18
18 "github.com/bnema/dumber/internal/config"19 "github.com/bnema/dumber/internal/config"
@@ -113,6 +114,12 @@ func (fc *FaviconCache) getFilename(faviconURL string) string {
113 114
114 // downloadFavicon downloads a favicon and saves it to cache115 // downloadFavicon downloads a favicon and saves it to cache
115 func (fc *FaviconCache) downloadFavicon(faviconURL string) error {116 func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
117+ // Skip SVG files - we can't convert them to PNG without additional dependencies
118+ if strings.HasSuffix(strings.ToLower(faviconURL), ".svg") {
119+ log.Printf("[favicon] Skipping SVG favicon: %s", faviconURL)
120+ return nil
121+ }
122+
116 filename := fc.getFilename(faviconURL)123 filename := fc.getFilename(faviconURL)
117 cachedPath := filepath.Join(fc.cacheDir, filename)124 cachedPath := filepath.Join(fc.cacheDir, filename)
118 125
@@ -177,9 +184,7 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
177 if _, err := io.Copy(downloadFile, limitedBody); err != nil {184 if _, err := io.Copy(downloadFile, limitedBody); err != nil {
178 return fmt.Errorf("failed to save favicon data: %w", err)185 return fmt.Errorf("failed to save favicon data: %w", err)
179 }186 }
180- if err := downloadFile.Close(); err != nil {187+ // Note: downloadFile will be closed by the defer above, no need to close explicitly
181- log.Printf("[favicon] failed to close download file: %v", err)
182- }
183 188
184 // Detect if it's an ICO file and convert with ffmpeg if needed189 // Detect if it's an ICO file and convert with ffmpeg if needed
185 if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {190 if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {
M internal/config/config.go +48 -1
@@ -40,6 +40,8 @@ type Config struct {
40 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`40 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
41 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.41 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
42 UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`42 UseDomZoom bool `mapstructure:"use_dom_zoom" yaml:"use_dom_zoom"`
43+ // DefaultZoom sets the default zoom level for pages without saved zoom settings (1.0 = 100%, 1.2 = 120%)
44+ DefaultZoom float64 `mapstructure:"default_zoom" yaml:"default_zoom"`
43 // Workspace defines workspace, pane, and tab handling behaviour.45 // Workspace defines workspace, pane, and tab handling behaviour.
44 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"`46 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"`
45 }47 }
@@ -118,7 +120,20 @@ type AppearanceConfig struct {
118 SerifFont string `mapstructure:"serif_font" yaml:"serif_font"`120 SerifFont string `mapstructure:"serif_font" yaml:"serif_font"`
119 MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`121 MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
120 // Default font size in CSS pixels (approx).122 // Default font size in CSS pixels (approx).
121- DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"`123+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"`
124+ LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette"`
125+ DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette"`
126+}
127+
128+// ColorPalette contains semantic color tokens for light/dark themes.
129+type ColorPalette struct {
130+ Background string `mapstructure:"background" yaml:"background" json:"background"`
131+ Surface string `mapstructure:"surface" yaml:"surface" json:"surface"`
132+ SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" json:"surface_variant"`
133+ Text string `mapstructure:"text" yaml:"text" json:"text"`
134+ Muted string `mapstructure:"muted" yaml:"muted" json:"muted"`
135+ Accent string `mapstructure:"accent" yaml:"accent" json:"accent"`
136+ Border string `mapstructure:"border" yaml:"border" json:"border"`
122 }137 }
123 138
124 // VideoAccelerationConfig holds video hardware acceleration preferences.139 // VideoAccelerationConfig holds video hardware acceleration preferences.
@@ -368,6 +383,9 @@ func NewManager() (*Manager, error) {
368 if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {383 if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {
369 return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)384 return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)
370 }385 }
386+ if err := v.BindEnv("default_zoom", "DUMBER_DEFAULT_ZOOM"); err != nil {
387+ return nil, fmt.Errorf("failed to bind DUMBER_DEFAULT_ZOOM: %w", err)
388+ }
371 389
372 // Video acceleration environment variable bindings390 // Video acceleration environment variable bindings
373 videoAccelEnvBindings := map[string]string{391 videoAccelEnvBindings := map[string]string{
@@ -665,6 +683,8 @@ func (m *Manager) setDefaults() {
665 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)683 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
666 m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)684 m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
667 m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)685 m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
686+ m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
687+ m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
668 688
669 // Video acceleration defaults689 // Video acceleration defaults
670 m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)690 m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)
@@ -701,6 +721,7 @@ func (m *Manager) setDefaults() {
701 // Rendering defaults721 // Rendering defaults
702 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))722 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
703 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)723 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
724+ m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
704 725
705 // Workspace defaults726 // Workspace defaults
706 m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)727 m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
@@ -752,6 +773,31 @@ func (m *Manager) ensurePersistedDefaults(cfg *Config) {
752 logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile))773 logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile))
753 }774 }
754 775
776+// persistDefaultZoomIfMissing persists the default_zoom value to the config file if it's not already set.
777+func (m *Manager) persistDefaultZoomIfMissing(cfg *Config) {
778+ if cfg == nil {
779+ return
780+ }
781+
782+ // Only persist when the key is missing from the user config file.
783+ if m.viper.InConfig("default_zoom") {
784+ return
785+ }
786+
787+ cfgFile := m.viper.ConfigFileUsed()
788+ if cfgFile == "" {
789+ return
790+ }
791+
792+ m.viper.Set("default_zoom", cfg.DefaultZoom)
793+ if err := m.viper.WriteConfig(); err != nil {
794+ logging.Warn(fmt.Sprintf("Config: failed to persist default_zoom default to %s: %v", cfgFile, err))
795+ return
796+ }
797+
798+ logging.Info(fmt.Sprintf("Config: persisted missing default_zoom default (%.2f) to %s", cfg.DefaultZoom, cfgFile))
799+}
800+
755 // createDefaultConfig creates a default configuration file.801 // createDefaultConfig creates a default configuration file.
756 func (m *Manager) createDefaultConfig() error {802 func (m *Manager) createDefaultConfig() error {
757 configFile, err := GetConfigFile()803 configFile, err := GetConfigFile()
@@ -768,6 +814,7 @@ func (m *Manager) createDefaultConfig() error {
768 defaultConfig := DefaultConfig()814 defaultConfig := DefaultConfig()
769 815
770 // Marshal to JSON with proper indentation816 // Marshal to JSON with proper indentation
817+ // Note: Go 1.20+ automatically sorts map keys in JSON output
771 configData, err := json.MarshalIndent(defaultConfig, "", " ")818 configData, err := json.MarshalIndent(defaultConfig, "", " ")
772 if err != nil {819 if err != nil {
773 return fmt.Errorf("failed to marshal default config: %w", err)820 return fmt.Errorf("failed to marshal default config: %w", err)
M internal/config/defaults.go +43 -41
@@ -60,24 +60,7 @@ func DefaultConfig() *Config {
60 RetentionPeriodDays: defaultRetentionDays, // 1 year60 RetentionPeriodDays: defaultRetentionDays, // 1 year
61 CleanupIntervalDays: 1, // daily cleanup61 CleanupIntervalDays: 1, // daily cleanup
62 },62 },
63- SearchShortcuts: map[string]SearchShortcut{63+ SearchShortcuts: GetDefaultSearchShortcuts(),
64- "g": {
65- URL: "https://www.google.com/search?q=%s",
66- Description: "Google search",
67- },
68- "gh": {
69- URL: "https://github.com/search?q=%s",
70- Description: "GitHub search",
71- },
72- "yt": {
73- URL: "https://www.youtube.com/results?search_query=%s",
74- Description: "YouTube search",
75- },
76- "w": {
77- URL: "https://en.wikipedia.org/wiki/%s",
78- Description: "Wikipedia search",
79- },
80- },
81 Dmenu: DmenuConfig{64 Dmenu: DmenuConfig{
82 MaxHistoryItems: defaultMaxHistoryItems,65 MaxHistoryItems: defaultMaxHistoryItems,
83 ShowVisitCount: true,66 ShowVisitCount: true,
@@ -110,6 +93,24 @@ func DefaultConfig() *Config {
110 SerifFont: "Fira Sans",93 SerifFont: "Fira Sans",
111 MonospaceFont: "Fira Code",94 MonospaceFont: "Fira Code",
112 DefaultFontSize: defaultFontSize,95 DefaultFontSize: defaultFontSize,
96+ LightPalette: ColorPalette{
97+ Background: "#f8f8f8",
98+ Surface: "#f2f2f2",
99+ SurfaceVariant: "#ececec",
100+ Text: "#1a1a1a",
101+ Muted: "#6e6e6e",
102+ Accent: "#404040",
103+ Border: "#d2d2d2",
104+ },
105+ DarkPalette: ColorPalette{
106+ Background: "#0e0e0e",
107+ Surface: "#1a1a1a",
108+ SurfaceVariant: "#141414",
109+ Text: "#e4e4e4",
110+ Muted: "#848484",
111+ Accent: "#a8a8a8",
112+ Border: "#363636",
113+ },
113 },114 },
114 VideoAcceleration: VideoAccelerationConfig{115 VideoAcceleration: VideoAccelerationConfig{
115 EnableVAAPI: true,116 EnableVAAPI: true,
@@ -127,7 +128,7 @@ func DefaultConfig() *Config {
127 DisableVP9Hardware: false, // Allow VP9 hardware for high res content128 DisableVP9Hardware: false, // Allow VP9 hardware for high res content
128 VideoBufferSizeMB: 16, // Larger buffer for AV1 streams129 VideoBufferSizeMB: 16, // Larger buffer for AV1 streams
129 QueueBufferTimeSec: 10, // More buffering time for smooth playback130 QueueBufferTimeSec: 10, // More buffering time for smooth playback
130- CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", // Chrome UA with AV1 support131+ CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", // Chrome UA with AV1 support
131 AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res132 AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
132 DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)133 DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)
133 },134 },
@@ -163,6 +164,7 @@ func DefaultConfig() *Config {
163 },164 },
164 RenderingMode: RenderingModeGPU,165 RenderingMode: RenderingModeGPU,
165 UseDomZoom: false,166 UseDomZoom: false,
167+ DefaultZoom: 1.2, // 120% default zoom for better readability
166 Workspace: WorkspaceConfig{168 Workspace: WorkspaceConfig{
167 EnableZellijControls: true,169 EnableZellijControls: true,
168 PaneMode: PaneModeConfig{170 PaneMode: PaneModeConfig{
@@ -209,6 +211,10 @@ func DefaultConfig() *Config {
209 // GetDefaultSearchShortcuts returns the default search shortcuts.211 // GetDefaultSearchShortcuts returns the default search shortcuts.
210 func GetDefaultSearchShortcuts() map[string]SearchShortcut {212 func GetDefaultSearchShortcuts() map[string]SearchShortcut {
211 return map[string]SearchShortcut{213 return map[string]SearchShortcut{
214+ "ddg": {
215+ URL: "https://duckduckgo.com/?q=%s",
216+ Description: "DuckDuckGo search",
217+ },
212 "g": {218 "g": {
213 URL: "https://www.google.com/search?q=%s",219 URL: "https://www.google.com/search?q=%s",
214 Description: "Google search",220 Description: "Google search",
@@ -217,37 +223,33 @@ func GetDefaultSearchShortcuts() map[string]SearchShortcut {
217 URL: "https://github.com/search?q=%s",223 URL: "https://github.com/search?q=%s",
218 Description: "GitHub search",224 Description: "GitHub search",
219 },225 },
220- "yt": {226+ "go": {
221- URL: "https://www.youtube.com/results?search_query=%s",227+ URL: "https://pkg.go.dev/search?q=%s",
222- Description: "YouTube search",228+ Description: "Go package search",
223- },
224- "w": {
225- URL: "https://en.wikipedia.org/wiki/%s",
226- Description: "Wikipedia search",
227 },229 },
228- "ddg": {230+ "mdn": {
229- URL: "https://duckduckgo.com/?q=%s",231+ URL: "https://developer.mozilla.org/en-US/search?q=%s",
230- Description: "DuckDuckGo search",232+ Description: "MDN Web Docs search",
231 },233 },
232- "so": {234+ "npm": {
233- URL: "https://stackoverflow.com/search?q=%s",235+ URL: "https://www.npmjs.com/search?q=%s",
234- Description: "Stack Overflow search",236+ Description: "npm package search",
235 },237 },
236 "r": {238 "r": {
237 URL: "https://www.reddit.com/search?q=%s",239 URL: "https://www.reddit.com/search?q=%s",
238 Description: "Reddit search",240 Description: "Reddit search",
239 },241 },
240- "npm": {242+ "so": {
241- URL: "https://www.npmjs.com/search?q=%s",243+ URL: "https://stackoverflow.com/search?q=%s",
242- Description: "npm package search",244+ Description: "Stack Overflow search",
243 },245 },
244- "go": {246+ "w": {
245- URL: "https://pkg.go.dev/search?q=%s",247+ URL: "https://en.wikipedia.org/wiki/%s",
246- Description: "Go package search",248+ Description: "Wikipedia search",
247 },249 },
248- "mdn": {250+ "yt": {
249- URL: "https://developer.mozilla.org/en-US/search?q=%s",251+ URL: "https://www.youtube.com/results?search_query=%s",
250- Description: "MDN Web Docs search",252+ Description: "YouTube search",
251 },253 },
252 }254 }
253 }255 }
M internal/db/history.sql.go +23 -1
@@ -73,6 +73,28 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error
73 return items, nil73 return items, nil
74 }74 }
75 75
76+const GetHistoryEntry = `-- name: GetHistoryEntry :one
77+SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
78+FROM history
79+WHERE url = ?
80+LIMIT 1
81+`
82+
83+func (q *Queries) GetHistoryEntry(ctx context.Context, url string) (History, error) {
84+ row := q.db.QueryRowContext(ctx, GetHistoryEntry, url)
85+ var i History
86+ err := row.Scan(
87+ &i.ID,
88+ &i.Url,
89+ &i.Title,
90+ &i.VisitCount,
91+ &i.LastVisited,
92+ &i.CreatedAt,
93+ &i.FaviconUrl,
94+ )
95+ return i, err
96+}
97+
76 const GetHistoryWithOffset = `-- name: GetHistoryWithOffset :many98 const GetHistoryWithOffset = `-- name: GetHistoryWithOffset :many
77 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url99 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
78 FROM history100 FROM history
@@ -186,7 +208,7 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col
186 }208 }
187 209
188 const UpdateHistoryFavicon = `-- name: UpdateHistoryFavicon :exec210 const UpdateHistoryFavicon = `-- name: UpdateHistoryFavicon :exec
189-UPDATE history 211+UPDATE history
190 SET favicon_url = ?212 SET favicon_url = ?
191 WHERE url = ?213 WHERE url = ?
192 `214 `
M internal/db/querier.go +1 -0
@@ -20,6 +20,7 @@ type Querier interface {
20 DeleteZoomLevel(ctx context.Context, domain string) error20 DeleteZoomLevel(ctx context.Context, domain string) error
21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
22 GetHistory(ctx context.Context, limit int64) ([]History, error)22 GetHistory(ctx context.Context, limit int64) ([]History, error)
23+ GetHistoryEntry(ctx context.Context, url string) (History, error)
23 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)24 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
24 GetShortcuts(ctx context.Context) ([]Shortcut, error)25 GetShortcuts(ctx context.Context) ([]Shortcut, error)
25 // Get zoom level for a specific domain26 // Get zoom level for a specific domain
M internal/db/queries/history.sql +7 -1
@@ -27,10 +27,16 @@ ORDER BY visit_count DESC, last_visited DESC
27 LIMIT ?;27 LIMIT ?;
28 28
29 -- name: UpdateHistoryFavicon :exec29 -- name: UpdateHistoryFavicon :exec
30-UPDATE history 30+UPDATE history
31 SET favicon_url = ?31 SET favicon_url = ?
32 WHERE url = ?;32 WHERE url = ?;
33 33
34+-- name: GetHistoryEntry :one
35+SELECT *
36+FROM history
37+WHERE url = ?
38+LIMIT 1;
39+
34 -- name: DeleteHistory :exec40 -- name: DeleteHistory :exec
35 DELETE FROM history41 DELETE FROM history
36 WHERE id = ?;42 WHERE id = ?;
M internal/services/browser.go +34 -4
@@ -446,7 +446,7 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er
446 // GetZoomLevel retrieves the saved zoom level for a URL.446 // GetZoomLevel retrieves the saved zoom level for a URL.
447 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {447 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
448 if url == "" {448 if url == "" {
449- return 1.0, nil449+ return s.config.DefaultZoom, nil
450 }450 }
451 451
452 key := zoomKeyFromURL(url)452 key := zoomKeyFromURL(url)
@@ -458,10 +458,10 @@ func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64,
458 if err2 == nil {458 if err2 == nil {
459 return zl, nil459 return zl, nil
460 }460 }
461- // No zoom setting found, return default461+ // No zoom setting found, return configured default
462- return 1.0, nil462+ return s.config.DefaultZoom, nil
463 }463 }
464- return 1.0, err464+ return s.config.DefaultZoom, err
465 }465 }
466 466
467 return zoomLevel, nil467 return zoomLevel, nil
@@ -636,6 +636,11 @@ func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
636 }636 }
637 }637 }
638 638
639+// ProcessFaviconURI is a public wrapper for handling favicon URI changes from any webview
640+func (s *BrowserService) ProcessFaviconURI(pageURL string, faviconURI string) {
641+ s.handleFaviconURIChanged(pageURL, faviconURI)
642+}
643+
639 // handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database644 // handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database
640 func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) {645 func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) {
641 log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI)646 log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI)
@@ -663,4 +668,29 @@ func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI stri
663 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {668 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
664 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)669 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
665 }670 }
671+
672+ // Download and cache the favicon asynchronously (same approach as dmenu)
673+ if faviconCache, err := cache.NewFaviconCache(); err == nil {
674+ faviconCache.CacheAsync(faviconURI)
675+ log.Printf("[favicon] Started async download of favicon from %s for page %s", faviconURI, pageURL)
676+ } else {
677+ log.Printf("[favicon] Failed to create favicon cache: %v", err)
678+ }
679+}
680+
681+// ColorPalettesResponse holds light and dark palettes for JSON marshaling
682+type ColorPalettesResponse struct {
683+ Light config.ColorPalette `json:"light"`
684+ Dark config.ColorPalette `json:"dark"`
685+}
686+
687+// GetColorPalettesForMessaging returns the color palettes from config
688+func (s *BrowserService) GetColorPalettesForMessaging() ColorPalettesResponse {
689+ if s.config == nil {
690+ return ColorPalettesResponse{}
691+ }
692+ return ColorPalettesResponse{
693+ Light: s.config.Appearance.LightPalette,
694+ Dark: s.config.Appearance.DarkPalette,
695+ }
666 }696 }
M pkg/webkit/settings.go +20 -0
@@ -48,11 +48,31 @@ type Config struct {
48 // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.48 // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.
49 UseDomZoom bool49 UseDomZoom bool
50 50
51+ // Colors defines the light/dark palette tokens for the GUI.
52+ Colors ColorPalettes
53+
51 // CreateWindow controls whether to create a window during WebView initialization.54 // CreateWindow controls whether to create a window during WebView initialization.
52 // Set to false when creating WebViews for workspace panes that will be reparented.55 // Set to false when creating WebViews for workspace panes that will be reparented.
53 CreateWindow bool56 CreateWindow bool
54 }57 }
55 58
59+// ColorPalette holds semantic UI tokens for a theme.
60+type ColorPalette struct {
61+ Background string
62+ Surface string
63+ SurfaceVariant string
64+ Text string
65+ Muted string
66+ Accent string
67+ Border string
68+}
69+
70+// ColorPalettes hosts both light and dark color palettes.
71+type ColorPalettes struct {
72+ Light ColorPalette
73+ Dark ColorPalette
74+}
75+
56 // CodecPreferencesConfig holds codec preferences for WebKit media playback76 // CodecPreferencesConfig holds codec preferences for WebKit media playback
57 type CodecPreferencesConfig struct {77 type CodecPreferencesConfig struct {
58 PreferredCodecs []string78 PreferredCodecs []string
M pkg/webkit/webview_cgo.go +6 -1
@@ -1305,7 +1305,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
1305 log.Printf("[webkit] Set custom User-Agent: %s", cfg.CodecPreferences.CustomUserAgent)1305 log.Printf("[webkit] Set custom User-Agent: %s", cfg.CodecPreferences.CustomUserAgent)
1306 } else if cfg.CodecPreferences.ForceAV1 {1306 } else if cfg.CodecPreferences.ForceAV1 {
1307 // Use modern Chrome UA that signals AV1 support1307 // Use modern Chrome UA that signals AV1 support
1308- av1UA := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"1308+ av1UA := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
1309 cAV1UA := C.CString(av1UA)1309 cAV1UA := C.CString(av1UA)
1310 C.webkit_settings_set_user_agent(settings, (*C.gchar)(cAV1UA))1310 C.webkit_settings_set_user_agent(settings, (*C.gchar)(cAV1UA))
1311 C.free(unsafe.Pointer(cAV1UA))1311 C.free(unsafe.Pointer(cAV1UA))
@@ -1844,6 +1844,7 @@ func (w *WebView) CreateRelatedView() *WebView {
1844 VideoAcceleration: w.config.VideoAcceleration,1844 VideoAcceleration: w.config.VideoAcceleration,
1845 Memory: w.config.Memory,1845 Memory: w.config.Memory,
1846 CodecPreferences: w.config.CodecPreferences,1846 CodecPreferences: w.config.CodecPreferences,
1847+ Colors: w.config.Colors,
1847 CreateWindow: false,1848 CreateWindow: false,
1848 }1849 }
1849 related, err := NewWebViewWithRelated(cfg, w)1850 related, err := NewWebViewWithRelated(cfg, w)
@@ -2060,6 +2061,8 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
2060 C.webkit_user_script_unref(schemeScript)2061 C.webkit_user_script_unref(schemeScript)
2061 }2062 }
2062 2063
2064+ // Color palette injection now handled in main-world.ts via __PALETTE_JSON__ placeholder
2065+
2063 // Add GUI bundle as user script at document-start (contains toast, omnibox, and controls)2066 // Add GUI bundle as user script at document-start (contains toast, omnibox, and controls)
2064 if cfg != nil && cfg.Assets != nil {2067 if cfg != nil && cfg.Assets != nil {
2065 log.Printf("[webkit] Attempting to load GUI bundle from assets/gui/gui.min.js")2068 log.Printf("[webkit] Attempting to load GUI bundle from assets/gui/gui.min.js")
@@ -2209,6 +2212,8 @@ func buildDomBridgeScript(cfg *Config, initialZoom float64, webViewId uintptr, w
2209 log.Printf("[webkit] Building script with WebView %s, active state: %s", webViewIdStr, activeState)2212 log.Printf("[webkit] Building script with WebView %s, active state: %s", webViewIdStr, activeState)
2210 script = strings.ReplaceAll(script, "\"__WEBVIEW_ACTIVE__\"", activeState)2213 script = strings.ReplaceAll(script, "\"__WEBVIEW_ACTIVE__\"", activeState)
2211 2214
2215+ // Color palettes and search shortcuts are now loaded via messaging bridge
2216+
2212 log.Printf("[webkit] Loaded main-world script successfully, size: %d bytes", len(scriptBytes))2217 log.Printf("[webkit] Loaded main-world script successfully, size: %d bytes", len(scriptBytes))
2213 return script2218 return script
2214 }2219 }
M pkg/webkit/workspace_widgets_cgo.go +110 -0
@@ -33,6 +33,7 @@ extern gboolean goIdleCallback(uintptr_t handle);
33 extern void goHoverCallback(uintptr_t handle);33 extern void goHoverCallback(uintptr_t handle);
34 extern void goFocusEnterCallback(uintptr_t handle);34 extern void goFocusEnterCallback(uintptr_t handle);
35 extern void goFocusLeaveCallback(uintptr_t handle);35 extern void goFocusLeaveCallback(uintptr_t handle);
36+extern void goTitleBarClickCallback(uintptr_t handle);
36 37
37 // Main thread helpers implemented in thread_helpers.c38 // Main thread helpers implemented in thread_helpers.c
38 void store_main_thread_id();39 void store_main_thread_id();
@@ -51,6 +52,26 @@ static gboolean remove_idle_callback(uintptr_t handle) {
51 return g_idle_remove_by_data((gpointer)handle);52 return g_idle_remove_by_data((gpointer)handle);
52 }53 }
53 54
55+// Title bar click callback
56+static void title_bar_click_cb(GtkGestureClick* gesture, gint n_press, gdouble x, gdouble y, gpointer user_data) {
57+ (void)gesture; (void)n_press; (void)x; (void)y;
58+ uintptr_t handle = (uintptr_t)user_data;
59+ goTitleBarClickCallback(handle);
60+}
61+
62+// Attach click handler to a widget (for title bars)
63+static void widget_attach_click_handler(GtkWidget* widget, uintptr_t handle) {
64+ if (!widget) {
65+ return;
66+ }
67+ GtkGesture* click = gtk_gesture_click_new();
68+ if (!click) {
69+ return;
70+ }
71+ g_signal_connect_data(click, "pressed", G_CALLBACK(title_bar_click_cb), (gpointer)handle, NULL, 0);
72+ gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(click));
73+}
74+
54 static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) {75 static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) {
55 (void)controller; (void)x; (void)y;76 (void)controller; (void)x; (void)y;
56 uintptr_t handle = (uintptr_t)user_data;77 uintptr_t handle = (uintptr_t)user_data;
@@ -367,6 +388,26 @@ static void widget_get_allocation_modern(GtkWidget* widget, int* x, int* y, int*
367 *height = gtk_widget_get_height(widget);388 *height = gtk_widget_get_height(widget);
368 }389 }
369 }390 }
391+
392+// GtkImage helpers for favicon display
393+static GtkWidget* image_new(void) {
394+ return gtk_image_new();
395+}
396+
397+static GtkWidget* image_new_from_file(const char* filename) {
398+ if (!filename) return NULL;
399+ return gtk_image_new_from_file(filename);
400+}
401+
402+static void image_set_from_file(GtkWidget* image, const char* filename) {
403+ if (!image || !filename) return;
404+ gtk_image_set_from_file(GTK_IMAGE(image), filename);
405+}
406+
407+static void image_set_pixel_size(GtkWidget* image, int pixel_size) {
408+ if (!image) return;
409+ gtk_image_set_pixel_size(GTK_IMAGE(image), pixel_size);
410+}
370 */411 */
371 import "C"412 import "C"
372 413
@@ -411,6 +452,11 @@ var (
411 focusControllers = make(map[uintptr]uintptr)452 focusControllers = make(map[uintptr]uintptr)
412 nextFocusID uintptr = 1453 nextFocusID uintptr = 1
413 454
455+ // Title bar click management
456+ titleBarMu sync.Mutex
457+ titleBarCallbacks = make(map[uintptr]func())
458+ nextTitleBarID uintptr = 1
459+
414 mainThreadInitialized bool460 mainThreadInitialized bool
415 )461 )
416 462
@@ -862,6 +908,22 @@ func WidgetRemoveHoverHandler(widget uintptr, token uintptr) {
862 C.widget_remove_controller((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.GtkEventController)(unsafe.Pointer(controller)))908 C.widget_remove_controller((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.GtkEventController)(unsafe.Pointer(controller)))
863 }909 }
864 910
911+// WidgetAttachClickHandler attaches a click handler to any widget (for title bars)
912+func WidgetAttachClickHandler(widget uintptr, fn func()) uintptr {
913+ if widget == 0 || fn == nil || !widgetIsValid(widget) {
914+ return 0
915+ }
916+ titleBarMu.Lock()
917+ token := nextTitleBarID
918+ nextTitleBarID++
919+ titleBarCallbacks[token] = fn
920+ titleBarMu.Unlock()
921+
922+ C.widget_attach_click_handler((*C.GtkWidget)(unsafe.Pointer(widget)), C.uintptr_t(token))
923+
924+ return token
925+}
926+
865 // WidgetGetBounds returns the absolute bounds of the widget relative to the root window.927 // WidgetGetBounds returns the absolute bounds of the widget relative to the root window.
866 func WidgetGetBounds(widget uintptr) (WidgetBounds, bool) {928 func WidgetGetBounds(widget uintptr) (WidgetBounds, bool) {
867 if widget == 0 || !widgetIsValid(widget) {929 if widget == 0 || !widgetIsValid(widget) {
@@ -938,6 +1000,17 @@ func goHoverCallback(handle C.uintptr_t) {
938 }1000 }
939 }1001 }
940 1002
1003+//export goTitleBarClickCallback
1004+func goTitleBarClickCallback(handle C.uintptr_t) {
1005+ token := uintptr(handle)
1006+ titleBarMu.Lock()
1007+ fn := titleBarCallbacks[token]
1008+ titleBarMu.Unlock()
1009+ if fn != nil {
1010+ fn()
1011+ }
1012+}
1013+
941 // GtkBox functions for stacked panes1014 // GtkBox functions for stacked panes
942 1015
943 // NewBox creates a new GtkBox widget with the specified orientation and spacing.1016 // NewBox creates a new GtkBox widget with the specified orientation and spacing.
@@ -1238,3 +1311,40 @@ func WidgetGetAllocation(widget uintptr) WidgetAllocation {
1238 Height: int(height),1311 Height: int(height),
1239 }1312 }
1240 }1313 }
1314+
1315+// GtkImage functions for favicon display
1316+
1317+// NewImage creates a new empty GtkImage widget.
1318+func NewImage() uintptr {
1319+ image := C.image_new()
1320+ return uintptr(unsafe.Pointer(image))
1321+}
1322+
1323+// ImageNewFromFile creates a new GtkImage widget from a file path.
1324+func ImageNewFromFile(filename string) uintptr {
1325+ if filename == "" {
1326+ return 0
1327+ }
1328+ cstr := C.CString(filename)
1329+ defer C.free(unsafe.Pointer(cstr))
1330+ image := C.image_new_from_file(cstr)
1331+ return uintptr(unsafe.Pointer(image))
1332+}
1333+
1334+// ImageSetFromFile updates an existing GtkImage to display an image from a file.
1335+func ImageSetFromFile(image uintptr, filename string) {
1336+ if image == 0 || filename == "" {
1337+ return
1338+ }
1339+ cstr := C.CString(filename)
1340+ defer C.free(unsafe.Pointer(cstr))
1341+ C.image_set_from_file((*C.GtkWidget)(unsafe.Pointer(image)), cstr)
1342+}
1343+
1344+// ImageSetPixelSize sets the pixel size for the image (used for scaling icons).
1345+func ImageSetPixelSize(image uintptr, pixelSize int) {
1346+ if image == 0 {
1347+ return
1348+ }
1349+ C.image_set_pixel_size((*C.GtkWidget)(unsafe.Pointer(image)), C.int(pixelSize))
1350+}

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