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.
55 ## [Unreleased]
66
77 ### 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
810 - **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
911 - **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
1016
1117 ### 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
1223 - **History item layout**: Removed "• domain" from titles, now highlight domain within the full URL using same color as title text
1324 - **Quick access logic**: Removed 20-item limit, now shows all sites with 2+ visits (pinned sites bypass visit requirement)
1425 - **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
1528
1629 ### Fixed
1730 - **Mobile URL overflow**: Fixed long URLs breaking layout on narrow screens by adding proper width constraints and CSS fixes
1831 - **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)
1933 - **Focus management architecture**: Refactored workspace focus system from `active` field to `currentlyFocused` with dedicated FocusManager for improved reliability and consistency across pane operations
2034 - **Stacked pane focus conflicts**: Fixed focus stealing issues in stacked panes by implementing focus-aware navigation that preserves active pane when spawning siblings
2135 - **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 @@
1818
1919 <style>
2020 .footer {
21- margin-top: 1rem;
21+ margin-top: 1.5rem;
2222 width: 100%;
23+ border-top: 1px solid var(--dynamic-border);
24+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
2325 }
2426
2527 .footer-content {
2628 display: flex;
2729 justify-content: center;
2830 align-items: center;
29- padding: 1rem 0;
31+ padding: 0.75rem 0;
3032 }
3133
3234 .footer-text {
3335 display: flex;
3436 align-items: center;
3537 gap: 0.75rem;
36- font-size: 0.85rem;
37- color: #888;
38+ font-size: 0.75rem;
39+ text-transform: uppercase;
40+ letter-spacing: 0.12em;
41+ color: var(--dynamic-muted);
3842 }
3943
4044 .footer-name {
41- color: #aaa;
42- font-weight: 500;
45+ color: var(--dynamic-text);
46+ font-weight: 600;
4347 }
4448
4549 .footer-dot {
46- color: #666;
50+ color: var(--dynamic-border);
4751 }
4852
4953 .footer-link {
5054 background: none;
5155 border: none;
52- color: #0066cc;
56+ color: var(--dynamic-muted);
5357 text-decoration: none;
5458 cursor: pointer;
5559 font-size: 0.85rem;
5660 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;
5868 }
5969
6070 .footer-link:hover {
61- color: #4d94ff;
62- text-decoration: underline;
71+ color: var(--dynamic-text);
72+ letter-spacing: 0.16em;
6373 }
6474
6575 .footer-link:focus {
66- outline: 1px solid #0066cc;
76+ outline: 1px solid var(--dynamic-border);
6777 outline-offset: 2px;
68- border-radius: 2px;
78+ border-radius: 0;
6979 }
7080 </style>
M gui/src/components/omnibox/Omnibox.svelte +66 -36
@@ -177,18 +177,20 @@
177177 // defensive: ignore if setting styles fails for some reason
178178 }
179179
180- const surfaceMix = isDarkMode ? '65%' : '92%';
181- const inputMix = isDarkMode ? '60%' : '94%';
182- const borderMix = isDarkMode ? '70%' : '90%';
183- const blurMix = isDarkMode ? '28%' : '16%';
180+ const surfaceMix = isDarkMode ? '55%' : '88%';
181+ const inputMix = isDarkMode ? '52%' : '90%';
182+ const borderMix = isDarkMode ? '62%' : '85%';
183+ const blurMix = isDarkMode ? '22%' : '12%';
184184
185- const containerBackground = faded
185+ const applyFade = faded && mode === 'find';
186+
187+ const containerBackground = applyFade
186188 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
187189 : 'var(--dynamic-surface)';
188- const inputBackground = faded
190+ const inputBackground = applyFade
189191 ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)`
190192 : 'var(--dynamic-bg)';
191- const borderColor = faded
193+ const borderColor = applyFade
192194 ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, transparent)`
193195 : 'var(--dynamic-border)';
194196 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
@@ -196,7 +198,8 @@
196198 boxElement.style.background = containerBackground;
197199 boxElement.style.color = 'var(--dynamic-text)';
198200 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%)');
200203
201204 inputElement.style.background = inputBackground;
202205 inputElement.style.color = 'var(--dynamic-text)';
@@ -217,24 +220,17 @@
217220 // If computed styles fail, keep fallback accent color
218221 }
219222 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)');
221224
222225 const isFocused = document.activeElement === inputElement;
223226
224227 if (!isFocused) {
225- inputElement.style.setProperty('--dumber-input-border-color', borderColor);
228+ inputElement.style.setProperty('--dumber-input-border-color', 'var(--dynamic-border)');
226229 }
227230
228- if (faded) {
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;
231+ if (applyFade) {
234232 blurLayerElement.style.opacity = '1';
235233 } else {
236- inputElement.style.textShadow = '';
237- boxElement.style.textShadow = '';
238234 blurLayerElement.style.opacity = '0';
239235 }
240236 });
@@ -333,7 +329,7 @@
333329 };
334330
335331 console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
336-
332+
337333 // Check for pending suggestions that arrived before API was ready
338334 if ((window as any).__dumber_omnibox_pending_suggestions) {
339335 console.log('🔄 [OMNIBOX] Processing pending suggestions');
@@ -341,6 +337,12 @@
341337 omniboxBridge.setSuggestions(pending);
342338 delete (window as any).__dumber_omnibox_pending_suggestions;
343339 }
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+ }
344346 } catch (error) {
345347 console.error('❌ Failed to set up omnibox global API:', error);
346348 }
@@ -388,21 +390,26 @@
388390 <!-- Main omnibox container -->
389391 <div
390392 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"
392394 style="position: relative !important;
393395 left: 50% !important;
394396 transform: translateX(-50%) !important;
395- margin-top: 8vh !important;
397+ margin-top: 20vh !important;
396398 margin-left: 0 !important;
397399 margin-right: 0 !important;
398400 width: {responsiveStyles.width};
399401 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;
401403 pointer-events: auto !important;
402404 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%));
404406 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);
406413 --dumber-border-color: var(--dynamic-border);"
407414 onmousedown={handleBoxClick}
408415 onmouseenter={handleMouseEnter}
@@ -418,7 +425,7 @@
418425 bind:this={blurLayerElement}
419426 class="dumber-omnibox-blur-layer"
420427 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;"
422429 ></div>
423430 <div class="dumber-omnibox-content">
424431 <!-- Input component -->
@@ -436,36 +443,59 @@
436443
437444 <style>
438445 .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%));
440447 color: var(--dynamic-text);
441- border: 2px solid var(--dumber-border-color, var(--dynamic-border));
442- transition: background-color 160ms ease, border-color 160ms ease;
448+ border: 1px solid var(--dumber-border-color, var(--dynamic-border));
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);
443456 }
444457
445458 .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);
447465 }
448466
449467 :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%));
451469 color: var(--dynamic-text);
452- border-color: var(--dumber-input-border-color, var(--dynamic-border));
453- transition: border-color 120ms ease, background-color 120ms ease;
470+ border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
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;
454481 }
455482
456483 :global(.dumber-omnibox-container input::placeholder) {
457484 color: var(--dynamic-muted);
485+ letter-spacing: 0.08em;
486+ text-transform: uppercase;
458487 }
459488
460489 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
461490 .dumber-omnibox-blur-layer {
462- backdrop-filter: blur(2px) saturate(110%);
463- -webkit-backdrop-filter: blur(2px) saturate(110%);
464- background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 16%, transparent));
465- transition: opacity 160ms ease;
491+ backdrop-filter: blur(1.5px) saturate(105%);
492+ -webkit-backdrop-filter: blur(1.5px) saturate(105%);
493+ background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 12%, transparent));
494+ transition: opacity 140ms ease;
466495 will-change: opacity;
467496 z-index: 0;
468497 mix-blend-mode: normal;
498+ border-radius: 0;
469499 }
470500
471501 /* Ensure the actual content (input, suggestions) sits above the blur layer */
M gui/src/components/omnibox/OmniboxFind.svelte +49 -17
@@ -54,17 +54,9 @@
5454 });
5555 </script>
5656
57-<div
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->
57+<div id="find-list" class="find-list" role="listbox" aria-label="Find results">
6358 <!-- Header with match count -->
64- <div
65- class="px-2.5 py-1.5 text-xs text-[#bbb]
66- border-b border-[#2a2a2a]"
67- >
59+ <div class="find-header">
6860 {#if totalMatches > 0}
6961 {totalMatches} match{totalMatches === 1 ? '' : 'es'}
7062 {:else}
@@ -79,9 +71,7 @@
7971
8072 <div
8173 id="find-item-{index}"
82- class="px-2.5 py-2 cursor-pointer
83- border-b border-[#2a2a2a] last:border-b-0
84- {isSelected ? 'bg-[#0a0a0a]' : ''}"
74+ class={isSelected ? 'find-item selected' : 'find-item'}
8575 role="option"
8676 tabindex="-1"
8777 aria-selected={isSelected}
@@ -90,10 +80,7 @@
9080 onkeydown={(e) => e.key === 'Enter' && handleItemClick(index)}
9181 >
9282 <!-- Match context -->
93- <div
94- class="text-[#ddd] text-sm
95- whitespace-nowrap overflow-hidden text-ellipsis"
96- >
83+ <div class="find-context">
9784 {match.context || ''}
9885 </div>
9986 </div>
@@ -101,3 +88,48 @@
10188 {/if}
10289 </div>
10390
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 @@
1414 let selectedIndex = $derived(omniboxStore.selectedIndex);
1515 let suggestions = $derived(omniboxStore.suggestions);
1616 let matches = $derived(omniboxStore.matches);
17+ let searchShortcuts = $derived(omniboxStore.searchShortcuts);
1718
1819 // Input element ref and responsive styles (props for parent to bind)
1920 interface Props {
@@ -28,6 +29,21 @@
2829
2930 const ACCENT_FALLBACK = 'var(--dynamic-accent)';
3031
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+
3147 function resolveAccentColor(): string {
3248 if (!inputElement) return ACCENT_FALLBACK;
3349 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
@@ -48,6 +64,50 @@
4864 mode === 'find' ? 'Find in page…' : 'Type URL or search…'
4965 );
5066
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+
51111 // Handle input changes
52112 function handleInput(event: Event) {
53113 const target = event.target as HTMLInputElement;
@@ -133,8 +193,12 @@
133193 omniboxStore.selectPrevious();
134194 }
135195
136- // Fade overlay while navigating results
137- omniboxStore.setFaded(true);
196+ // Fade overlay while navigating results only for find mode
197+ if (mode === 'find') {
198+ omniboxStore.setFaded(true);
199+ } else {
200+ omniboxStore.setFaded(false);
201+ }
138202
139203 // For find mode, reveal the current match
140204 if (mode === 'find') {
@@ -218,37 +282,50 @@
218282 }
219283 </script>
220284
221-<input
222- bind:this={inputElement}
223- type="text"
224- {placeholder}
225- value={inputValue}
226- class="w-full box-border omnibox-input omnibox-input-field rounded focus:outline-none"
227- style="padding: {responsiveStyles?.inputPadding || '10px 12px'};
228- font-size: {responsiveStyles?.fontSize || '16px'};
229- box-sizing: border-box;"
230- oninput={handleInput}
231- onkeydown={handleKeyDown}
232- onmousedown={handleMouseDown}
233- onclick={handleClick}
234- onfocus={handleFocus}
235- onblur={handleBlur}
236- onmouseenter={handleMouseEnter}
237- autocomplete="off"
238- spellcheck="false"
239- role="combobox"
240- aria-expanded={omniboxStore.hasContent}
241- aria-controls="omnibox-list"
242- aria-haspopup="listbox"
243- aria-owns="omnibox-list"
244- aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
245-/>
285+<div class="omnibox-input-wrapper">
286+ {#if activeBadge}
287+ <span
288+ class="omnibox-prefix-badge"
289+ aria-hidden="true"
290+ style={`left: ${baseLeftPadding};`}
291+ >{activeBadge.label}</span>
292+ {/if}
293+
294+ <input
295+ bind:this={inputElement}
296+ type="text"
297+ {placeholder}
298+ value={inputValue}
299+ class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
300+ style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
301+ oninput={handleInput}
302+ onkeydown={handleKeyDown}
303+ onmousedown={handleMouseDown}
304+ onclick={handleClick}
305+ onfocus={handleFocus}
306+ onblur={handleBlur}
307+ onmouseenter={handleMouseEnter}
308+ autocomplete="off"
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>
246318
247319 <style>
320+ .omnibox-input-wrapper {
321+ position: relative;
322+ }
323+
248324 .omnibox-input-field {
249325 background: var(--dumber-input-bg, var(--dynamic-bg));
250326 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;
252329 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
253330 }
254331
@@ -259,4 +336,25 @@
259336 .omnibox-input-field:focus {
260337 color: var(--dynamic-text);
261338 }
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+ }
262360 </style>
M gui/src/components/omnibox/OmniboxSuggestions.svelte +85 -20
@@ -20,7 +20,7 @@
2020 // Handle suggestion item mouse enter
2121 function handleItemMouseEnter(index: number) {
2222 omniboxStore.setSelectedIndex(index);
23- omniboxStore.setFaded(true);
23+ omniboxStore.setFaded(false);
2424 scrollToSelection();
2525 }
2626
@@ -86,7 +86,7 @@
8686 {#if hasContent}
8787 <div
8888 id="omnibox-list"
89- class="mt-2 max-h-[50vh] overflow-auto border-t border-[#333]"
89+ class="suggestion-list"
9090 role="listbox"
9191 aria-label="Search suggestions"
9292 >
@@ -96,9 +96,7 @@
9696
9797 <div
9898 id="omnibox-item-{index}"
99- class="px-2.5 py-2 flex gap-2.5 items-center cursor-pointer
100- border-b border-[#2a2a2a] last:border-b-0
101- {isSelected ? 'bg-[#0a0a0a]' : ''}"
99+ class={isSelected ? 'suggestion-item selected' : 'suggestion-item'}
102100 role="option"
103101 tabindex={isSelected ? 0 : -1}
104102 aria-selected={isSelected}
@@ -108,15 +106,11 @@
108106 >
109107 <!-- Favicon chip -->
110108 {#if suggestion.favicon}
111- <div
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- >
109+ <div class="suggestion-favicon">
116110 <img
117111 src={suggestion.favicon}
118112 alt=""
119- class="w-4 h-4 filter brightness-105 contrast-103"
113+ class="suggestion-favicon-img"
120114 loading="lazy"
121115 onerror={handleFaviconError}
122116 style="image-rendering: -webkit-optimize-contrast;"
@@ -125,24 +119,19 @@
125119 {/if}
126120
127121 <!-- URL text with gradient fade -->
128- <div
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- >
122+ <div class="suggestion-text">
134123 <!-- Domain -->
135- <span class="text-[#e6e6e6] opacity-95">
124+ <span class="suggestion-domain">
136125 {domain}
137126 </span>
138127
139128 <!-- Separator -->
140- <span class="text-[#777]">
129+ <span class="suggestion-separator">
141130 {' | '}
142131 </span>
143132
144133 <!-- Path -->
145- <span class="text-[#99aadd]">
134+ <span class="suggestion-path">
146135 {path || '/'}
147136 </span>
148137 </div>
@@ -151,3 +140,79 @@
151140 </div>
152141 {/if}
153142
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 {
2323 background-color: rgb(255 235 59);
2424 color: rgb(0 0 0);
2525 padding: 0 0.125rem;
26- border-radius: 0.125rem;
26+ border-radius: 0;
2727 box-shadow: 0 0 0 1px rgb(200 185 0) inset;
2828 }
2929
M gui/src/components/omnibox/messaging.ts +61 -1
@@ -4,7 +4,7 @@
44 * TypeScript interface for Go-JavaScript communication
55 */
66
7-import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from "./types";
7+import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, SearchShortcut } from "./types";
88 import { omniboxStore } from "./stores.svelte.ts";
99
1010 export class OmniboxBridge implements OmniboxMessageBridge {
@@ -46,6 +46,14 @@ export class OmniboxBridge implements OmniboxMessageBridge {
4646 omniboxStore.updateSuggestions(suggestions);
4747 }
4848
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+
4957 /**
5058 * Handle navigation request
5159 */
@@ -65,6 +73,58 @@ export class OmniboxBridge implements OmniboxMessageBridge {
6573 this.postMessage({ type: "query", q, limit: lim });
6674 }
6775
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+
68128 // Suggestions are returned via setSuggestions() when native handler responds
69129 }
70130
M gui/src/components/omnibox/stores.svelte.ts +9 -0
@@ -10,6 +10,7 @@ import type {
1010 FindMatch,
1111 HighlightNode,
1212 OmniboxConfig,
13+ SearchShortcut,
1314 } from "./types";
1415
1516 // Default configuration
@@ -31,6 +32,7 @@ let inputValue = $state("");
3132 let highlightNodes = $state<HighlightNode[]>([]);
3233 let prevOverflow = $state("");
3334 let debounceTimer = $state(0);
35+let searchShortcuts = $state<Record<string, SearchShortcut>>({});
3436
3537 // Configuration
3638 let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
@@ -70,6 +72,9 @@ export const omniboxStore = {
7072 get config() {
7173 return config;
7274 },
75+ get searchShortcuts() {
76+ return searchShortcuts;
77+ },
7378
7479 // Computed getters
7580 get hasContent() {
@@ -233,6 +238,10 @@ export const omniboxStore = {
233238 config = { ...config, ...newConfig };
234239 },
235240
241+ updateSearchShortcuts(shortcuts: Record<string, SearchShortcut>) {
242+ searchShortcuts = shortcuts;
243+ },
244+
236245 reset() {
237246 visible = false;
238247 mode = "omnibox";
M gui/src/components/omnibox/types.ts +7 -1
@@ -9,8 +9,13 @@ export interface Suggestion {
99 favicon?: string;
1010 }
1111
12+export interface SearchShortcut {
13+ url: string;
14+ description: string;
15+}
16+
1217 export interface OmniboxMessage {
13- type: "navigate" | "query";
18+ type: "navigate" | "query" | "get_search_shortcuts";
1419 url?: string;
1520 q?: string;
1621 limit?: number;
@@ -45,6 +50,7 @@ export interface OmniboxState {
4550 inputValue: string;
4651 highlightNodes: HighlightNode[];
4752 prevOverflow: string;
53+ searchShortcuts: Record<string, SearchShortcut>;
4854 }
4955
5056 // Message bridge interface for Go communication
M gui/src/components/toast/Toast.svelte +17 -52
@@ -95,10 +95,13 @@
9595
9696 <style>
9797 .toast {
98- background-color: var(--dynamic-surface);
99- border: 1px solid var(--dynamic-border);
98+ --toast-bg: color-mix(in srgb, var(--dynamic-surface) 88%, var(--dynamic-text) 12%);
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);
100103 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);
102105 cursor: pointer;
103106 display: inline-flex;
104107 margin: 0;
@@ -118,7 +121,7 @@
118121 }
119122
120123 .toast-message {
121- color: var(--dynamic-text);
124+ color: var(--toast-text);
122125 font-size: 0.875rem;
123126 font-weight: 600;
124127 min-width: 9rem;
@@ -126,57 +129,19 @@
126129 text-align: center;
127130 }
128131
129- .toast-light.toast-info {
130- background-color: rgb(239 246 255);
131- border-color: rgb(191 219 254);
132+ .toast.toast-info {
133+ --toast-bg: color-mix(in srgb, var(--dynamic-surface) 82%, var(--dynamic-text) 18%);
134+ --toast-border: color-mix(in srgb, var(--dynamic-border) 55%, var(--dynamic-text) 45%);
132135 }
133136
134- .toast-dark.toast-info {
135- background-color: rgb(59 130 246 / 0.2);
136- border-color: rgb(59 130 246);
137+ .toast.toast-success {
138+ --toast-bg: color-mix(in srgb, var(--dynamic-surface) 78%, var(--dynamic-text) 22%);
139+ --toast-border: color-mix(in srgb, var(--dynamic-border) 50%, var(--dynamic-text) 50%);
137140 }
138141
139- .toast-light.toast-success {
140- background-color: rgb(240 253 244);
141- border-color: rgb(187 247 208);
142- }
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);
142+ .toast.toast-error {
143+ --toast-bg: color-mix(in srgb, var(--dynamic-surface) 74%, var(--dynamic-text) 26%);
144+ --toast-border: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
145+ --toast-text: color-mix(in srgb, var(--dynamic-text) 80%, var(--dynamic-muted) 20%);
181146 }
182147 </style>
M gui/src/injected/main-world.ts +144 -0
@@ -19,6 +19,12 @@ declare global {
1919 __dumber_dom_zoom_level?: number;
2020 __dumber_initial_theme?: string;
2121 __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;
2228 __dumber_applyDomZoom?: (level: number) => void;
2329 __dumber_showToast?: (
2430 message: string,
@@ -118,6 +124,134 @@ function detectWindowType(features?: string | null): string {
118124 window.__dumber_webview_id = "__WEBVIEW_ID__";
119125 window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
120126
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+
121255 // Theme setter function for GTK theme integration
122256 window.__dumber_setTheme = (theme: "light" | "dark") => {
123257 window.__dumber_initial_theme = theme;
@@ -127,6 +261,16 @@ function detectWindowType(features?: string | null): string {
127261 } else {
128262 document.documentElement.classList.remove("dark");
129263 }
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+ }
130274 };
131275
132276 // Initialize unified API object
M gui/src/injected/modules/color-scheme.ts +7 -0
@@ -187,6 +187,13 @@ class ColorSchemeManager {
187187 } else {
188188 document.documentElement.classList.remove("dark");
189189 }
190+ try {
191+ window.__dumber_applyPalette?.(
192+ this.config.isDark ? "dark" : "light",
193+ );
194+ } catch {
195+ /* noop */
196+ }
190197 }
191198 }, 100);
192199 }
M gui/src/injected/modules/omnibox.ts +7 -0
@@ -8,6 +8,7 @@ import { mount } from "svelte";
88 import { Omnibox } from "../../components/omnibox";
99 import { ensureShadowMount } from "./shadowHost";
1010 import type { OmniboxConfig } from "../../components/omnibox/types";
11+import { omniboxBridge } from "../../components/omnibox/messaging";
1112
1213 let omniboxComponent: ReturnType<typeof mount> | null = null;
1314 let isInitialized = false;
@@ -60,6 +61,12 @@ export async function initializeOmnibox(
6061 Object.keys(window.__dumber_omnibox),
6162 );
6263 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+
6370 // Notify isolated bundle listeners that omnibox is ready
6471 try {
6572 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 {
6969 }
7070
7171 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+}
72107
73108 /**
74109 * Ensure and return the global ShadowRoot used by injected UI.
@@ -87,6 +122,7 @@ export function getGlobalShadowRoot(): ShadowRoot {
87122
88123 cachedHost = host;
89124 ensureHostPresence(host);
125+ syncHostTokens(host);
90126
91127 const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: "open" });
92128
@@ -100,6 +136,7 @@ export function getGlobalShadowRoot(): ShadowRoot {
100136 "dark",
101137 document.documentElement.classList.contains("dark"),
102138 );
139+ syncHostTokens(host);
103140 });
104141 observer.observe(document.documentElement, {
105142 attributes: true,
@@ -109,6 +146,16 @@ export function getGlobalShadowRoot(): ShadowRoot {
109146 /* no-op */
110147 }
111148
149+ try {
150+ document.addEventListener("dumber:palette-updated", () => {
151+ if (cachedHost) {
152+ syncHostTokens(cachedHost);
153+ }
154+ });
155+ } catch {
156+ /* no-op */
157+ }
158+
112159 // Inject a minimal reset and base styles into the shadow root once
113160 if (!shadowResetApplied.has(shadowRoot)) {
114161 const resetStyle = document.createElement("style");
@@ -125,20 +172,13 @@ export function getGlobalShadowRoot(): ShadowRoot {
125172 tokensStyle.textContent = `
126173 :host {
127174 --dynamic-bg: var(--color-browser-bg);
175+ --dynamic-surface-variant: var(--color-browser-surface-variant);
128176 --dynamic-surface: var(--color-browser-surface);
129177 --dynamic-text: var(--color-browser-text);
130178 --dynamic-muted: var(--color-browser-muted);
131179 --dynamic-accent: var(--color-browser-accent);
132180 --dynamic-border: var(--color-browser-border);
133181 }
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- }
142182 `;
143183 shadowRoot.appendChild(tokensStyle);
144184
M gui/src/pages/Homepage.svelte +693 -388
@@ -25,6 +25,11 @@
2525 newest_entry?: string;
2626 }
2727
28+ interface SearchShortcut {
29+ url: string;
30+ description: string;
31+ }
32+
2833 let historyItems = $state<HistoryItem[]>([]);
2934 let topVisited = $state<HistoryItem[]>([]);
3035 let shortcuts = $state<Shortcut[]>([]);
@@ -33,6 +38,8 @@
3338 let topVisitedLoading = $state(true);
3439 let shortcutsLoading = $state(true);
3540 let statsLoading = $state(true);
41+ let searchShortcuts = $state<Record<string, SearchShortcut>>({});
42+ let searchShortcutsLoading = $state(true);
3643 let deletingIds = $state<number[]>([]);
3744 let historyOffset = $state(0);
3845 let hasMoreHistory = $state(true);
@@ -482,6 +489,39 @@
482489
483490 let quickAccess = $derived<HistoryItem[]>((pinnedSites.size, buildQuickAccess()));
484491 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+ })());
485525
486526 const persistTheme = (mode: ThemeMode) => {
487527 try {
@@ -589,6 +629,49 @@
589629 }
590630 };
591631
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+
592675 // Intersection Observer for infinite scroll
593676 let historyScrollSentinel = $state<HTMLElement | null>(null);
594677
@@ -613,6 +696,38 @@
613696 return () => observer.disconnect();
614697 };
615698
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+
616731 onMount(() => {
617732 loadPinnedSites();
618733 initializeShortcuts();
@@ -622,6 +737,7 @@
622737 });
623738 fetchStats();
624739 fetchTopVisited();
740+ fetchSearchShortcuts();
625741
626742 // Theme synchronization
627743 const syncThemeState = () => {
@@ -648,11 +764,37 @@
648764 <svelte:head>
649765 <title>Dumber Browser - Homepage</title>
650766 <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>`}
651785 </svelte:head>
652786
653787 <div class="homepage-shell">
654- <div class="homepage-content">
655- <div class="top-bar">
788+ <div class="terminal-frame">
789+ <header class="terminal-header">
790+ <div class="terminal-heading">
791+ <span class="terminal-path">dumb://home</span>
792+ <span class="terminal-meta">
793+ {statsLoading ? 'syncing…' : `${stats?.recent_count ?? 0} recent`}
794+ · {historyItems.length} entries
795+ · {pinnedSites.size} pinned
796+ </span>
797+ </div>
656798 <button
657799 class="theme-toggle-button"
658800 type="button"
@@ -667,7 +809,9 @@
667809 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
668810 <path
669811 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"
671815 />
672816 </svg>
673817 {:else}
@@ -686,9 +830,57 @@
686830 </svg>
687831 {/if}
688832 </button>
689- </div>
690- <div class="main-panels">
691- <section class="history-panel brutal-panel">
833+ </header>
834+
835+ <div class="terminal-body">
836+ <div class="terminal-status-row">
837+ <div class="status-chip">
838+ <span class="chip-label">history</span>
839+ <span class="chip-value">{historyItems.length}</span>
840+ </div>
841+ <div class="status-chip">
842+ <span class="chip-label">pinned</span>
843+ <span class="chip-value">{pinnedSites.size}</span>
844+ </div>
845+ <div class="status-chip">
846+ <span class="chip-label">quick-links</span>
847+ <span class="chip-value">{quickAccess.length}</span>
848+ </div>
849+ {#if stats && stats.total_entries}
850+ <div class="status-chip">
851+ <span class="chip-label">stored</span>
852+ <span class="chip-value">{stats.total_entries}</span>
853+ </div>
854+ {/if}
855+ </div>
856+
857+ <div
858+ class="last-visit-banner"
859+ role="button"
860+ tabindex={latestVisitInfo ? 0 : -1}
861+ onclick={() => latestVisitInfo && navigateTo(latestVisitInfo.entry.url)}
862+ onkeydown={(event) => {
863+ if (event.key === 'Enter' && latestVisitInfo) {
864+ navigateTo(latestVisitInfo.entry.url);
865+ }
866+ }}
867+ aria-label={latestVisitInfo ? `Open ${latestVisitInfo.title}` : 'No recorded visits'}
868+ >
869+ <span class="banner-label">last-visit</span>
870+ {#if latestVisitInfo}
871+ <span class="banner-title" use:checkOverflow>
872+ {latestVisitInfo.title}
873+ </span>
874+ <span class="banner-domain">{latestVisitInfo.domain}</span>
875+ <span class="banner-time">{latestVisitInfo.relative}</span>
876+ <span class="banner-absolute">{latestVisitInfo.absolute}</span>
877+ {:else}
878+ <span class="banner-empty">none recorded yet</span>
879+ {/if}
880+ </div>
881+
882+ <div class="terminal-grid main-panels">
883+ <section class="history-panel terminal-pane">
692884 <div class="panel-header">
693885 <h2 class="panel-title">Recent History</h2>
694886 <p class="panel-subtitle">Scroll to revisit anything from your latest sessions.</p>
@@ -751,10 +943,14 @@
751943 </div>
752944 <div class="history-item-content">
753945 <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>
755949 <div class="history-time">{formatTime(item.last_visited)}</div>
756950 </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>
758954 </div>
759955 <div class="history-actions">
760956 <div
@@ -770,7 +966,7 @@
770966 <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"/>
771967 </svg>
772968 {: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">
774970 <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"/>
775971 </svg>
776972 {/if}
@@ -808,7 +1004,7 @@
8081004 </div>
8091005 </section>
8101006
811- <section class="quick-access-panel brutal-panel">
1007+ <section class="quick-access-panel terminal-pane">
8121008 <div class="panel-header">
8131009 <h2 class="panel-title">Jump back in</h2>
8141010 <p class="panel-subtitle">Direct access to the sites you like the most.</p>
@@ -869,7 +1065,9 @@
8691065 {/if}
8701066 </div>
8711067 <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>
8731071 <div class="quick-access-domain-compact">{getDomain(item.url)}</div>
8741072 </div>
8751073 <div class="quick-access-actions-compact">
@@ -886,7 +1084,7 @@
8861084 <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"/>
8871085 </svg>
8881086 {: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">
8901088 <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"/>
8911089 </svg>
8921090 {/if}
@@ -899,132 +1097,189 @@
8991097 {/if}
9001098 </div>
9011099 </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>
9081100 </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-
9251101
926- <section class="stats-panel brutal-panel">
927- <div class="panel-header">
928- <h2 class="panel-title">Usage Stats</h2>
929- <p class="panel-subtitle">Your browsing activity at a glance.</p>
930- </div>
931- <div class="panel-body">
932- {#if statsLoading}
933- <div class="loading">Loading stats...</div>
934- {:else if stats}
935- <div class="stats-grid-horizontal">
936- <div class="stat-item-horizontal">
937- <div class="stat-value">{stats.total_entries}</div>
938- <div class="stat-label">Entries Stored</div>
1102+ <section class="search-shortcuts-panel terminal-pane full-width">
1103+ <div class="panel-header">
1104+ <h2 class="panel-title">Search Shortcuts</h2>
1105+ <p class="panel-subtitle">Quick search prefixes for faster browsing.</p>
1106+ </div>
1107+ <div class="panel-body">
1108+ {#if searchShortcutsLoading}
1109+ <div class="loading">Loading search shortcuts...</div>
1110+ {:else if Object.keys(searchShortcuts).length === 0}
1111+ <div class="empty-state compact">
1112+ <h3>No shortcuts configured</h3>
1113+ <p>Add search shortcuts to your config file.</p>
1114+ </div>
1115+ {:else}
1116+ <div class="shortcuts-list">
1117+ {#each Object.entries(searchShortcuts) as [key, shortcut] (key)}
1118+ <div class="shortcut-item">
1119+ <div class="shortcut-key-badge">{key}:</div>
1120+ <div class="shortcut-desc">{shortcut.description.toLowerCase()}</div>
1121+ </div>
1122+ {/each}
9391123 </div>
940- <div class="stat-item-horizontal">
941- <div class="stat-value">{stats.recent_count}</div>
942- <div class="stat-label">Recent Window</div>
1124+ {/if}
1125+ </div>
1126+ </section>
1127+
1128+ <section class="shortcuts-panel terminal-pane full-width">
1129+ <div class="panel-header">
1130+ <h2 class="panel-title">Keyboard Shortcuts</h2>
1131+ <p class="panel-subtitle">Stay quick with the essentials.</p>
1132+ </div>
1133+ <div class="panel-body">
1134+ {#if shortcutsLoading}
1135+ <div class="loading">Loading shortcuts...</div>
1136+ {:else}
1137+ <div class="shortcuts-list">
1138+ {#each shortcuts as shortcut (shortcut.key)}
1139+ <div class="shortcut-item">
1140+ <div class="shortcut-key-badge">{shortcut.key}</div>
1141+ <div class="shortcut-desc">{shortcut.description}</div>
1142+ </div>
1143+ {/each}
9431144 </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">
9451159 <div class="stat-item-horizontal">
946- <div class="stat-value">{formatTime(stats.newest_entry)}</div>
947- <div class="stat-label">Newest Visit</div>
1160+ <div class="stat-value">{stats.total_entries}</div>
1161+ <div class="stat-label">Entries Stored</div>
9481162 </div>
949- {/if}
950- {#if stats.oldest_entry}
9511163 <div class="stat-item-horizontal">
952- <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
953- <div class="stat-label">Oldest Entry</div>
1164+ <div class="stat-value">{stats.recent_count}</div>
1165+ <div class="stat-label">Recent Window</div>
9541166 </div>
955- {/if}
956- </div>
957- {:else}
958- <div class="empty-state compact">
959- <p>No statistics available</p>
960- </div>
961- {/if}
962- </div>
963- </section>
964-
1167+ {#if stats.newest_entry}
1168+ <div class="stat-item-horizontal">
1169+ <div class="stat-value">{formatTime(stats.newest_entry)}</div>
1170+ <div class="stat-label">Newest Visit</div>
1171+ </div>
1172+ {/if}
1173+ {#if stats.oldest_entry}
1174+ <div class="stat-item-horizontal">
1175+ <div class="stat-value">{formatCalendarDate(stats.oldest_entry)}</div>
1176+ <div class="stat-label">Oldest Entry</div>
1177+ </div>
1178+ {/if}
1179+ </div>
1180+ {:else}
1181+ <div class="empty-state compact">
1182+ <p>No statistics available</p>
1183+ </div>
1184+ {/if}
1185+ </div>
1186+ </section>
1187+ </div>
9651188 </div>
9661189
9671190 <Footer />
9681191 </div>
9691192
9701193 <style>
971-/* Homepage Layout Styles */
9721194 .homepage-shell {
9731195 min-height: 100vh;
9741196 background-color: var(--dynamic-bg);
9751197 color: var(--dynamic-text);
9761198 display: flex;
9771199 flex-direction: column;
978- padding: clamp(1.75rem, 3vw, 3rem);
979- font-family: "Fira Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1200+ padding: clamp(1.5rem, 4vw, 2.5rem);
1201+ font-family:
1202+ "JetBrains Mono",
1203+ "Fira Code",
1204+ "SFMono-Regular",
1205+ Menlo,
1206+ monospace;
1207+ line-height: 1.45;
9801208 }
9811209
9821210 .homepage-shell * {
9831211 box-sizing: border-box;
9841212 }
9851213
986-.homepage-content {
1214+.terminal-frame {
9871215 flex: 1;
9881216 width: 100%;
989- max-width: 1180px;
1217+ max-width: 1160px;
9901218 margin: 0 auto;
9911219 display: flex;
9921220 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);
9941226 }
9951227
996-.top-bar {
1228+.terminal-header {
9971229 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;
9991256 }
10001257
10011258 .theme-toggle-button {
1002- width: 3rem;
1003- height: 3rem;
1259+ width: 2.5rem;
1260+ height: 2.5rem;
10041261 display: inline-flex;
10051262 align-items: center;
10061263 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;
10081268 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;
10151269 cursor: pointer;
10161270 }
10171271
10181272 .theme-toggle-button:hover,
10191273 .theme-toggle-button:focus-visible {
1020- border-color: var(--dynamic-accent);
1021- color: var(--dynamic-accent);
1274+ border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1275+ color: var(--dynamic-text);
1276+ background-color: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
10221277 outline: none;
10231278 }
10241279
10251280 .theme-toggle-button svg {
1026- width: 1.5rem;
1027- height: 1.5rem;
1281+ width: 1.3rem;
1282+ height: 1.3rem;
10281283 }
10291284
10301285 .sr-only {
@@ -1039,99 +1294,159 @@
10391294 border: 0;
10401295 }
10411296
1042-.brutal-panel {
1043- border: 2px solid var(--dynamic-border);
1044- background-color: var(--dynamic-surface);
1045- border-radius: 0;
1297+.terminal-body {
10461298 display: flex;
10471299 flex-direction: column;
1048- gap: 0;
1300+ gap: 1.25rem;
1301+ padding: 1.25rem 1.5rem 1.5rem;
1302+ background: var(--dynamic-bg);
10491303 }
10501304
1305+.terminal-status-row {
1306+ display: flex;
1307+ flex-wrap: wrap;
1308+ gap: 0.75rem;
1309+}
10511310
1052-
1053-.loading {
1054- padding: 1.25rem;
1055- text-align: center;
1311+.status-chip {
1312+ display: inline-flex;
1313+ align-items: center;
1314+ gap: 0.5rem;
1315+ padding: 0.45rem 0.8rem;
1316+ border: 1px solid var(--dynamic-border);
1317+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1318+ font-size: 0.68rem;
1319+ letter-spacing: 0.1em;
1320+ text-transform: uppercase;
10561321 color: var(--dynamic-muted);
1057- border: 2px dashed var(--dynamic-border);
1058- background-color: var(--dynamic-bg);
10591322 }
10601323
1061-.empty-state {
1062- border: 2px dashed var(--dynamic-border);
1063- padding: 1.75rem;
1064- display: flex;
1065- flex-direction: column;
1066- gap: 0.5rem;
1067- text-align: center;
1324+.status-chip .chip-value {
1325+ color: var(--dynamic-text);
1326+ font-weight: 600;
1327+}
1328+
1329+.last-visit-banner {
1330+ margin-top: 0.9rem;
1331+ display: grid;
1332+ grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(120px, auto) minmax(120px, auto) minmax(120px, auto);
1333+ gap: 0.75rem;
10681334 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 {
10691359 color: var(--dynamic-muted);
1070- background-color: var(--dynamic-bg);
1360+ font-size: 0.68rem;
10711361 }
10721362
1073-.empty-state h3 {
1074- margin: 0;
1363+.banner-title {
1364+ font-weight: 600;
1365+ white-space: nowrap;
1366+ overflow: hidden;
1367+ letter-spacing: 0.06em;
1368+}
1369+
1370+:global(.banner-title.truncated) {
1371+ mask-image: linear-gradient(to right, black 85%, transparent 100%);
1372+ -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1373+}
1374+
1375+.banner-domain {
1376+ color: var(--dynamic-muted);
1377+ font-size: 0.68rem;
1378+ letter-spacing: 0.1em;
1379+}
1380+
1381+.banner-time {
1382+ font-size: 0.72rem;
10751383 color: var(--dynamic-text);
1076- font-size: 1.1rem;
1384+ white-space: nowrap;
10771385 }
10781386
1079-.empty-state p {
1080- margin: 0;
1387+.banner-absolute {
1388+ font-size: 0.68rem;
10811389 color: var(--dynamic-muted);
1390+ white-space: nowrap;
10821391 }
10831392
1084-.empty-state.compact {
1085- padding: 1.25rem;
1393+.banner-empty {
1394+ grid-column: 2 / -1;
1395+ color: var(--dynamic-muted);
1396+ font-size: 0.72rem;
1397+ letter-spacing: 0.1em;
1398+}
1399+
1400+.terminal-grid {
1401+ display: grid;
1402+ gap: 1rem;
10861403 }
10871404
1088-/* Main panels with height matching */
10891405 .main-panels {
1090- display: flex;
1091- flex-wrap: wrap;
1092- gap: clamp(1.5rem, 2vw, 2rem);
1093- align-items: flex-start;
1406+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
10941407 }
10951408
1096-.history-panel {
1097- flex: 1 1 55%;
1098- min-width: min(32rem, 100%);
1409+.terminal-pane {
1410+ border: 1px solid var(--dynamic-border);
1411+ background: color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%);
10991412 display: flex;
11001413 flex-direction: column;
11011414 min-height: 0;
1415+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dynamic-border) 18%, transparent);
11021416 }
11031417
1104-.quick-access-panel {
1105- flex: 1 1 35%;
1106- min-width: min(24rem, 100%);
1107- display: flex;
1108- flex-direction: column;
1109- min-height: 0;
1418+.terminal-pane.full-width {
1419+ width: 100%;
1420+ grid-column: 1 / -1;
11101421 }
11111422
11121423 .panel-header {
11131424 display: flex;
11141425 flex-direction: column;
1115- gap: 0.4rem;
1116- padding: clamp(1.25rem, 2vw, 1.75rem) clamp(1.5rem, 2.5vw, 2rem);
1117- border-bottom: 2px solid var(--dynamic-border);
1118- background-color: var(--dynamic-surface);
1426+ gap: 0.3rem;
1427+ padding: 1rem 1.1rem;
1428+ border-bottom: 1px solid var(--dynamic-border);
1429+ background: color-mix(in srgb, var(--dynamic-bg) 75%, transparent);
11191430 }
11201431
11211432 .panel-title {
11221433 margin: 0;
1123- font-size: 1.3rem;
1124- font-weight: 600;
1434+ font-size: 0.95rem;
1435+ text-transform: uppercase;
1436+ letter-spacing: 0.08em;
1437+ color: var(--dynamic-text);
11251438 }
11261439
11271440 .panel-subtitle {
11281441 margin: 0;
1129- font-size: 0.95rem;
1442+ font-size: 0.72rem;
11301443 color: var(--dynamic-muted);
1444+ letter-spacing: 0.1em;
1445+ text-transform: uppercase;
11311446 }
11321447
11331448 .panel-body {
1134- padding: clamp(1.25rem, 2vw, 1.75rem) clamp(1.5rem, 2.5vw, 2rem);
1449+ padding: 1.1rem 1.1rem 1.25rem;
11351450 display: flex;
11361451 flex-direction: column;
11371452 gap: 1.1rem;
@@ -1139,8 +1454,48 @@
11391454 overflow: hidden;
11401455 }
11411456
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+
11421497 .history-body {
1143- gap: 1rem;
1498+ gap: 0.75rem;
11441499 display: flex;
11451500 flex-direction: column;
11461501 min-height: 0;
@@ -1150,26 +1505,21 @@
11501505 .history-list {
11511506 display: flex;
11521507 flex-direction: column;
1153- gap: 0.85rem;
11541508 flex: 1;
11551509 min-height: 0;
11561510 overflow-y: auto;
1157- background-color: var(--dynamic-surface-variant);
1158- border: 2px solid var(--dynamic-border);
1159- padding: 0.5rem;
1511+ border: 1px solid var(--dynamic-border);
1512+ background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-surface) 5%);
11601513 }
11611514
11621515 .history-item {
1163- display: flex;
1164- align-items: flex-start;
1516+ display: grid;
1517+ grid-template-columns: auto 1fr auto;
1518+ align-items: center;
11651519 gap: 0.75rem;
1166- border-bottom: 1px solid var(--dynamic-border);
1167- background-color: var(--dynamic-surface-variant);
1168- padding: 0.75rem 0.5rem;
1169- cursor: pointer;
1170- transition:
1171- background-color 200ms ease,
1172- border-color 200ms ease;
1520+ padding: 0.6rem 0.9rem;
1521+ border-bottom: 1px dashed var(--dynamic-border);
1522+ transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
11731523 }
11741524
11751525 .history-item:last-child {
@@ -1178,26 +1528,25 @@
11781528
11791529 .history-item:hover,
11801530 .history-item:focus-visible {
1181- background-color: var(--dynamic-surface);
1531+ background: color-mix(in srgb, var(--dynamic-bg) 65%, var(--dynamic-surface) 35%);
11821532 outline: none;
11831533 }
11841534
11851535 .history-item.deleting {
1186- opacity: 0.4;
1536+ opacity: 0.35;
11871537 pointer-events: none;
1188- transform: translateX(6px);
1538+ transform: translateX(4px);
11891539 }
11901540
11911541 .history-item-leading {
1192- width: 44px;
1193- height: 44px;
1194- border: 2px solid var(--dynamic-border);
1195- background-color: var(--dynamic-surface);
1542+ width: 38px;
1543+ height: 38px;
1544+ border: 1px solid var(--dynamic-border);
1545+ background-color: var(--dynamic-bg);
11961546 display: flex;
11971547 align-items: center;
11981548 justify-content: center;
11991549 flex-shrink: 0;
1200- border-radius: 0;
12011550 }
12021551
12031552 .history-favicon-img {
@@ -1215,21 +1564,22 @@
12151564 color: var(--dynamic-muted);
12161565 }
12171566
1567+.history-favicon-fallback svg {
1568+ opacity: 0.55;
1569+}
1570+
12181571 .history-item-content {
1219- flex: 1;
1220- min-width: 0;
12211572 display: flex;
12221573 flex-direction: column;
1223- gap: 0.25rem;
1224- justify-content: center;
1225- overflow: hidden;
1574+ gap: 0.2rem;
1575+ min-width: 0;
12261576 }
12271577
12281578 .history-item-main {
12291579 display: flex;
1230- align-items: flex-start;
1580+ align-items: center;
12311581 justify-content: space-between;
1232- gap: 1rem;
1582+ gap: 0.75rem;
12331583 }
12341584
12351585 .history-title {
@@ -1237,34 +1587,35 @@
12371587 color: var(--dynamic-text);
12381588 white-space: nowrap;
12391589 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%);
12441590 }
12451591
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+}
12461600
12471601 .history-time {
1248- font-size: 0.85rem;
1602+ font-size: 0.72rem;
12491603 color: var(--dynamic-muted);
1250- flex-shrink: 0;
1604+ text-transform: uppercase;
1605+ letter-spacing: 0.08em;
1606+ white-space: nowrap;
12511607 }
12521608
12531609 .history-url {
1254- font-size: 0.8rem;
1610+ font-size: 0.72rem;
12551611 color: var(--dynamic-muted);
12561612 white-space: nowrap;
1257- font-family:
1258- "JetBrains Mono",
1259- "SFMono-Regular",
1260- Menlo,
1261- monospace;
12621613 overflow: hidden;
1263- flex: 1;
1264- min-width: 0;
1265- position: relative;
1266- mask-image: linear-gradient(to right, black 70%, transparent 100%);
1267- -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
1614+}
1615+
1616+:global(.history-url.truncated) {
1617+ mask-image: linear-gradient(to right, black 85%, transparent 100%);
1618+ -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
12681619 }
12691620
12701621 .history-url :global(.history-url-domain) {
@@ -1274,364 +1625,318 @@
12741625
12751626 .history-actions {
12761627 display: flex;
1277- flex-shrink: 0;
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;
1628+ gap: 0.35rem;
12971629 }
12981630
1631+.history-pin,
12991632 .history-delete {
1300- display: flex;
1633+ display: inline-flex;
13011634 align-items: center;
13021635 justify-content: center;
1303- width: 38px;
1304- border-left: 2px solid var(--dynamic-border);
1305- background-color: var(--dynamic-surface);
1636+ width: 28px;
1637+ height: 28px;
1638+ border: 1px solid var(--dynamic-border);
13061639 color: var(--dynamic-muted);
1307- font-size: 1.1rem;
1308- transition: background-color 200ms ease, color 200ms ease;
1309- flex-shrink: 0;
1640+ background: transparent;
1641+ transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
13101642 }
13111643
1644+.history-pin:hover,
1645+.history-pin:focus-visible,
13121646 .history-delete:hover,
13131647 .history-delete:focus-visible {
1314- color: var(--dynamic-accent);
1315- background-color: var(--dynamic-bg);
1648+ color: var(--dynamic-text);
1649+ border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1650+ background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
13161651 outline: none;
13171652 }
13181653
13191654 .scroll-sentinel {
1320- width: 100%;
13211655 height: 1px;
13221656 }
13231657
13241658 .loading-more {
13251659 padding: 0.75rem;
1660+ font-size: 0.7rem;
13261661 text-align: center;
13271662 color: var(--dynamic-muted);
1328- font-size: 0.85rem;
1663+ letter-spacing: 0.08em;
1664+ text-transform: uppercase;
13291665 }
13301666
13311667 .quick-access-body {
1332- gap: 1rem;
1668+ gap: 0.9rem;
13331669 }
13341670
13351671 .quick-access-grid-compact {
13361672 display: flex;
13371673 flex-direction: column;
1338- gap: 0.5rem;
1339- background-color: var(--dynamic-surface-variant);
1340- border: 2px solid var(--dynamic-border);
1341- padding: 0.5rem;
1674+ gap: 0.75rem;
13421675 }
13431676
13441677 .quick-access-item-compact {
1345- display: flex;
1346- align-items: center;
1678+ border: 1px solid var(--dynamic-border);
1679+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1680+ padding: 0.75rem 0.9rem;
1681+ display: grid;
1682+ grid-template-columns: auto 1fr auto;
13471683 gap: 0.75rem;
1348- border-bottom: 1px solid var(--dynamic-border);
1349- background-color: var(--dynamic-surface-variant);
1350- padding: 0.5rem 0.25rem;
1684+ align-items: center;
1685+ transition: background-color 150ms ease, border-color 150ms ease;
13511686 cursor: pointer;
1352- transition: background-color 200ms ease;
1353-}
1354-
1355-.quick-access-item-compact:last-child {
1356- border-bottom: none;
13571687 }
13581688
13591689 .quick-access-item-compact:hover,
13601690 .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%);
13621693 outline: none;
13631694 }
13641695
13651696 .quick-access-favicon-compact {
1366- width: 32px;
1367- height: 32px;
1368- border: 2px solid var(--dynamic-border);
1369- background-color: var(--dynamic-surface-variant);
1697+ width: 36px;
1698+ height: 36px;
1699+ border: 1px solid var(--dynamic-border);
13701700 display: flex;
13711701 align-items: center;
13721702 justify-content: center;
1373- flex-shrink: 0;
1374- border-radius: 0;
1703+ background: var(--dynamic-bg);
13751704 }
13761705
13771706 .quick-access-favicon-img-compact {
1378- width: 70%;
1379- height: 70%;
1707+ width: 60%;
1708+ height: 60%;
13801709 object-fit: contain;
13811710 }
13821711
13831712 .quick-access-fallback-compact {
1384- width: 100%;
1385- height: 100%;
13861713 display: flex;
13871714 align-items: center;
13881715 justify-content: center;
1716+ width: 100%;
1717+ height: 100%;
13891718 color: var(--dynamic-muted);
13901719 }
13911720
1721+.quick-access-fallback-compact svg {
1722+ opacity: 0.55;
1723+}
1724+
13921725 .quick-access-meta-compact {
1393- flex: 1;
1394- min-width: 0;
13951726 display: flex;
13961727 flex-direction: column;
1397- gap: 0.125rem;
1728+ gap: 0.2rem;
1729+ min-width: 0;
13981730 }
13991731
14001732 .quick-access-title-compact {
1401- font-size: 0.95rem;
1402- font-weight: 600;
14031733 color: var(--dynamic-text);
1734+ font-weight: 600;
14041735 white-space: nowrap;
14051736 overflow: hidden;
1406- position: relative;
1407- mask-image: linear-gradient(to right, black 70%, transparent 100%);
1408- -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%);
1737+}
1738+
1739+:global(.quick-access-title-compact.truncated) {
1740+ mask-image: linear-gradient(to right, black 85%, transparent 100%);
1741+ -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
1742+}
1743+
1744+.quick-access-title-compact:hover {
1745+ cursor: pointer;
14091746 }
14101747
14111748 .quick-access-domain-compact {
1412- font-size: 0.8rem;
14131749 color: var(--dynamic-muted);
1414- white-space: nowrap;
1415- overflow: hidden;
1416- text-overflow: ellipsis;
1750+ font-size: 0.72rem;
1751+ text-transform: uppercase;
1752+ letter-spacing: 0.08em;
14171753 }
14181754
14191755 .quick-access-actions-compact {
14201756 display: flex;
1421- align-items: center;
1422- gap: 0.5rem;
1423- flex-shrink: 0;
1757+ flex-direction: column;
1758+ gap: 0.3rem;
1759+ align-items: flex-end;
1760+ justify-content: center;
14241761 }
14251762
14261763 .quick-access-pin-compact {
1427- display: flex;
1764+ display: inline-flex;
14281765 align-items: center;
14291766 justify-content: center;
1430- width: 24px;
1431- height: 24px;
1767+ width: 26px;
1768+ height: 26px;
14321769 border: 1px solid var(--dynamic-border);
1433- background-color: var(--dynamic-surface-variant);
14341770 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;
14361773 cursor: pointer;
1437- border-radius: 0;
14381774 }
14391775
14401776 .quick-access-pin-compact:hover,
14411777 .quick-access-pin-compact:focus-visible {
1442- color: var(--dynamic-accent);
1443- background-color: var(--dynamic-surface);
1778+ color: var(--dynamic-text);
1779+ border-color: color-mix(in srgb, var(--dynamic-border) 45%, var(--dynamic-text) 55%);
1780+ background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
14441781 outline: none;
14451782 }
14461783
1447-.quick-access-visits-compact {
1448- font-size: 0.75rem;
1449- color: var(--dynamic-accent);
1450- text-transform: uppercase;
1451- letter-spacing: 0.05em;
1452- font-weight: 600;
1453- flex-shrink: 0;
1784+.quick-access-pin-compact svg {
1785+ width: 14px;
1786+ height: 14px;
14541787 }
14551788
1456-
1457-.stat-value {
1458- font-size: 1.4rem;
1459- font-weight: 600;
1460- color: var(--dynamic-text);
1789+.quick-access-visits-compact {
1790+ font-size: 0.7rem;
1791+ color: var(--dynamic-muted);
1792+ text-transform: uppercase;
1793+ letter-spacing: 0.08em;
14611794 }
14621795
1463-.stat-label {
1464- font-size: 0.8rem;
1796+.show-more-button {
1797+ width: 100%;
1798+ padding: 0.65rem 0.75rem;
1799+ border: 1px solid var(--dynamic-border);
1800+ background: transparent;
14651801 color: var(--dynamic-muted);
1802+ font-size: 0.72rem;
14661803 text-transform: uppercase;
14671804 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;
14681816 }
14691817
14701818 .stats-grid-horizontal {
1471- display: flex;
1472- flex-wrap: wrap;
1473- gap: 1rem;
1474- justify-content: space-between;
1819+ display: grid;
1820+ gap: 0.85rem;
1821+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
14751822 }
14761823
14771824 .stat-item-horizontal {
1478- flex: 1;
1479- min-width: 120px;
1480- border: 2px solid var(--dynamic-border);
1481- background-color: var(--dynamic-surface-variant);
1482- padding: 0.75rem;
1825+ border: 1px solid var(--dynamic-border);
1826+ padding: 0.85rem;
14831827 display: flex;
14841828 flex-direction: column;
1485- gap: 0.25rem;
1486- text-align: center;
1487- border-radius: 0;
1829+ gap: 0.4rem;
1830+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
1831+ text-transform: uppercase;
1832+ letter-spacing: 0.08em;
14881833 }
14891834
1490-.shortcuts-panel {
1491- gap: 0;
1835+.stat-value {
1836+ font-size: 0.95rem;
1837+ color: var(--dynamic-text);
1838+ font-weight: 600;
1839+}
1840+
1841+.stat-label {
1842+ font-size: 0.68rem;
1843+ color: var(--dynamic-muted);
14921844 }
14931845
14941846 .shortcuts-list {
1495- display: flex;
1496- flex-wrap: wrap;
1497- gap: 0.85rem;
1847+ display: grid;
1848+ grid-template-columns: repeat(2, 1fr);
1849+ gap: 0.6rem;
14981850 }
14991851
15001852 .shortcut-item {
1501- display: flex;
1853+ display: grid;
1854+ grid-template-columns: auto 1fr;
1855+ gap: 0.6rem;
15021856 align-items: center;
1503- gap: 0.75rem;
1504- border: 2px solid var(--dynamic-border);
1505- background-color: var(--dynamic-bg);
1506- padding: 0.75rem 1rem;
1507- border-radius: 0;
1857+ border: 1px solid var(--dynamic-border);
1858+ padding: 0.6rem 0.75rem;
1859+ background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
15081860 }
15091861
15101862 .shortcut-key-badge {
1511- font-family:
1512- "JetBrains Mono",
1513- "SFMono-Regular",
1514- Menlo,
1515- monospace;
1516- font-size: 0.85rem;
1863+ font-size: 0.72rem;
15171864 text-transform: uppercase;
15181865 letter-spacing: 0.08em;
1519- padding: 0.35rem 0.65rem;
1520- border: 2px 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;
1866+ padding: 0.25rem 0.6rem;
1867+ border: 1px solid var(--dynamic-border);
15291868 color: var(--dynamic-text);
1869+ background: color-mix(in srgb, var(--dynamic-bg) 80%, var(--dynamic-surface) 20%);
15301870 }
15311871
1532-.shortcuts-panel .loading {
1533- border-style: solid;
1872+.shortcut-desc {
1873+ font-size: 0.72rem;
1874+ color: var(--dynamic-muted);
1875+ letter-spacing: 0.06em;
15341876 }
15351877
1536-/* Responsive Design */
1537-@media (max-width: 1200px) {
1538- .history-panel,
1539- .quick-access-panel {
1540- min-width: min(28rem, 100%);
1878+@media (max-width: 960px) {
1879+ .terminal-body {
1880+ padding: 1.1rem;
15411881 }
1542-}
15431882
1544-@media (max-width: 960px) {
1545- .homepage-shell {
1546- padding: clamp(1.25rem, 5vw, 2rem);
1883+ .terminal-grid {
1884+ gap: 0.75rem;
15471885 }
15481886
15491887 .main-panels {
1550- flex-direction: column;
1888+ grid-template-columns: 1fr;
15511889 }
15521890
1553- .history-panel,
1554- .quick-access-panel {
1555- min-width: 100%;
1556- max-width: 100%;
1557- width: 100%;
1891+ .last-visit-banner {
1892+ grid-template-columns: minmax(0, auto) minmax(0, 1fr);
1893+ grid-template-rows: repeat(3, auto);
1894+ gap: 0.5rem 0.75rem;
15581895 }
15591896
1897+ .banner-time,
1898+ .banner-absolute {
1899+ justify-self: flex-start;
1900+ }
15601901 }
15611902
15621903 @media (max-width: 640px) {
1563- .theme-toggle-button {
1564- width: 2.75rem;
1565- height: 2.75rem;
1566- }
1567-
1568- .panel-body,
1569- .panel-header {
1570- padding-left: 1.25rem;
1571- padding-right: 1.25rem;
1904+ .homepage-shell {
1905+ padding: 1.25rem;
15721906 }
15731907
1574-
1575- .history-item {
1908+ .terminal-header {
15761909 flex-direction: column;
15771910 align-items: flex-start;
15781911 }
15791912
1580- .history-actions {
1581- width: 100%;
1582- border-top: 2px solid var(--dynamic-border);
1913+ .theme-toggle-button {
1914+ align-self: flex-end;
15831915 }
15841916
1585- .history-pin,
1586- .history-delete {
1587- flex: 1;
1588- border-left: 0;
1917+ .terminal-status-row {
1918+ gap: 0.5rem;
15891919 }
15901920
1591- .history-pin {
1592- border-right: 1px solid var(--dynamic-border);
1921+ .status-chip {
1922+ padding: 0.35rem 0.65rem;
15931923 }
15941924
1595- .history-item-main {
1596- flex-direction: column;
1597- align-items: flex-start;
1598- gap: 0.35rem;
1925+ .last-visit-banner {
1926+ grid-template-columns: minmax(0, 1fr);
1927+ grid-template-rows: repeat(4, auto);
15991928 }
16001929
1601- .history-time {
1602- order: 3;
1603- }
1604-
1605- .stats-grid-horizontal {
1606- flex-direction: column;
1607- gap: 0.75rem;
1930+ .banner-label,
1931+ .banner-title,
1932+ .banner-domain,
1933+ .banner-time,
1934+ .banner-absolute {
1935+ justify-self: flex-start;
16081936 }
16091937
1610- .stat-item-horizontal {
1611- min-width: 100%;
1938+ .shortcuts-list {
1939+ grid-template-columns: 1fr;
16121940 }
16131941 }
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-}
16371942 </style>
M gui/src/services/app.ts +41 -50
@@ -79,57 +79,48 @@ export class AppService {
7979 }
8080
8181 async loadShortcuts(): Promise<void> {
82- try {
83- const cfg = await fetch("/api/config").then((r) => r.json());
84- const raw = cfg?.search_shortcuts || {};
85- // Normalize field casing from backend (supports URL/Description and url/description)
86- const normalized: Record<string, SearchShortcut> = {};
87- for (const [key, value] of Object.entries(raw)) {
88- const v = value as Record<string, unknown>;
89- normalized[key] = {
90- id: 0,
91- shortcut: key,
92- url_template: (v.url ?? v.URL ?? "") as string,
93- description: (v.description ?? v.Description ?? "") as string,
94- created_at: null,
95- };
96- }
97- this.shortcuts = normalized;
98- } catch (error) {
99- console.error("Failed to load shortcuts:", error);
100- // Mock shortcuts for development/fallback
101- this.shortcuts = {
102- g: {
103- id: 0,
104- shortcut: "g",
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- },
82+ return new Promise((resolve, reject) => {
83+ // Set up response handler
84+ window.__dumber_search_shortcuts = (data: unknown) => {
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 field casing from backend (supports URL/Description and url/description)
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+ id: 0,
97+ shortcut: key,
98+ url_template: (v.url ?? v.URL ?? "") as string,
99+ description: (v.description ?? v.Description ?? "") as string,
100+ created_at: null,
101+ };
102+ }
103+ this.shortcuts = normalized;
104+ resolve();
131105 };
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+ });
133124 }
134125
135126 getHistory(): HistoryEntry[] {
M gui/src/styles/tailwind.css +9 -59
@@ -1,17 +1,5 @@
11 @import "tailwindcss";
22
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-
153 /* Dynamic theme switching via CSS variables */
164 :root {
175 --dynamic-bg: var(--color-browser-bg);
@@ -23,17 +11,6 @@
2311 --dynamic-border: var(--color-browser-border);
2412 }
2513
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-
3714 /* Base styles for browser UI components */
3815 .browser-ui {
3916 background-color: var(--dynamic-bg);
@@ -44,7 +21,7 @@
4421
4522 /* Toast notification styles */
4623 .toast-base {
47- border-radius: 0.375rem; /* rounded-md */
24+ border-radius: 0;
4825 box-shadow:
4926 0 10px 15px -3px rgb(0 0 0 / 0.1),
5027 0 4px 6px -4px rgb(0 0 0 / 0.1);
@@ -56,47 +33,19 @@
5633 border: 1px solid var(--dynamic-border);
5734 }
5835
59-.toast-success {
60- background-color: rgb(240 253 244);
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-
36+.toast-success,
37+.toast-error,
8338 .toast-info {
84- background-color: rgb(239 246 255);
85- color: rgb(59 130 246);
86- border: 1px solid rgb(191 219 254);
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);
39+ background-color: var(--toast-bg, var(--dynamic-surface));
40+ color: var(--toast-text, var(--dynamic-text));
41+ border: 1px solid var(--toast-border, var(--dynamic-border));
9342 }
9443
9544 /* Omnibox/Find bar styles */
9645 .omnibox-base {
9746 background-color: var(--dynamic-bg);
9847 color: var(--dynamic-text);
99- border: 2px solid var(--dynamic-border);
48+ border: 1px solid var(--dynamic-border);
10049 box-shadow:
10150 0 4px 6px -1px rgb(0 0 0 / 0.1),
10251 0 2px 4px -2px rgb(0 0 0 / 0.1);
@@ -123,7 +72,7 @@
12372 .omnibox-results {
12473 background-color: var(--dynamic-bg);
12574 border: 1px solid var(--dynamic-border);
126- border-radius: 0.375rem;
75+ border-radius: 0;
12776 box-shadow:
12877 0 10px 15px -3px rgb(0 0 0 / 0.1),
12978 0 4px 6px -4px rgb(0 0 0 / 0.1);
@@ -171,6 +120,7 @@
171120
172121 .findbar-button {
173122 padding: 0.25rem 0.5rem;
123+ border-radius: 0;
174124 color: var(--dynamic-muted);
175125 transition: colors 150ms;
176126 font-size: 0.875rem;
M internal/app/browser/stacked_panes.go +157 -37
@@ -1,23 +1,34 @@
11 package browser
22
33 import (
4+ "context"
45 "errors"
56 "fmt"
67 "log"
8+ "sync/atomic"
79 "time"
810
11+ "github.com/bnema/dumber/internal/cache"
912 "github.com/bnema/dumber/pkg/webkit"
1013 )
1114
15+const (
16+ // faviconPlaceholder is the emoji displayed when no favicon is available
17+ faviconPlaceholder = "🌐"
18+)
19+
1220 // StackedPaneManager handles all stacked pane operations
1321 type StackedPaneManager struct {
14- wm *WorkspaceManager
22+ wm *WorkspaceManager
23+ titleBarToPane map[uintptr]*paneNode
24+ nextTitleBarID uint64
1525 }
1626
1727 // NewStackedPaneManager creates a new stacked pane manager
1828 func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {
1929 return &StackedPaneManager{
20- wm: wm,
30+ wm: wm,
31+ titleBarToPane: make(map[uintptr]*paneNode),
2132 }
2233 }
2334
@@ -409,6 +420,15 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
409420 }
410421 if pane.titleBar != 0 {
411422 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+ }
412432 }
413433 }
414434 }
@@ -494,43 +514,31 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
494514
495515 // createTitleBar creates a title bar widget for a pane in a stack
496516 func (spm *StackedPaneManager) createTitleBar(pane *paneNode) uintptr {
497- titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)
498- if titleBox == 0 {
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
517+ // Get the actual title and URL from the WebView
518+ var titleText, pageURL string
509519 if pane.pane != nil && pane.pane.webView != nil {
510520 titleText = pane.pane.webView.GetTitle()
511521 if titleText == "" {
512522 titleText = "New Tab" // Fallback only when title is actually empty
513523 }
524+ pageURL = pane.pane.webView.GetCurrentURL()
514525 } else {
515526 titleText = "New Tab"
516527 }
517528
518- // Create title label with the actual title
519- titleLabel := webkit.NewLabel(titleText)
520- if titleLabel == 0 {
521- log.Printf("[workspace] failed to create title label")
522- return titleBox
523- }
529+ titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
530+ if titleBar != 0 {
531+ // Store the mapping from titleBar ID to pane
532+ titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
533+ spm.titleBarToPane[uintptr(titleBarID)] = pane
524534
525- webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")
526- webkit.LabelSetMaxWidthChars(titleLabel, 50)
527- webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)
528-
529- webkit.BoxAppend(titleBox, titleLabel)
530- webkit.WidgetShow(titleBox)
531- webkit.WidgetShow(titleLabel)
535+ // Attach click handler
536+ webkit.WidgetAttachClickHandler(titleBar, func() {
537+ spm.handleTitleBarClick(uintptr(titleBarID))
538+ })
539+ }
532540
533- return titleBox
541+ return titleBar
534542 }
535543
536544 // UpdateTitleBar updates the title bar label for a WebView in stacked panes
@@ -578,19 +586,28 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
578586 return
579587 }
580588
581- // Format the title for display
582- displayTitle := title
583- if len(displayTitle) > 50 {
584- displayTitle = displayTitle[:47] + "..."
589+ // Get the current URL from the WebView for favicon
590+ var pageURL string
591+ if node.pane != nil && node.pane.webView != nil {
592+ pageURL = node.pane.webView.GetCurrentURL()
585593 }
586594
587- // Create new title bar with updated title
588- newTitleBar := spm.createTitleBarWithTitle(displayTitle)
595+ // Create new title bar with updated title and favicon
596+ newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
589597 if newTitleBar == 0 {
590598 log.Printf("[workspace] failed to create new title bar")
591599 return
592600 }
593601
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+
594611 // Replace the old title bar in the stack
595612 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != 0 {
596613 // 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)
628645 }
629646 }
630647
631-// createTitleBarWithTitle creates a title bar with a specific title
632-func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr {
648+// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
649+func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) uintptr {
633650 titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)
634651 if titleBox == 0 {
635652 return 0
@@ -639,6 +656,75 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr {
639656 webkit.WidgetSetHExpand(titleBox, true)
640657 webkit.WidgetSetVExpand(titleBox, false)
641658
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+
642728 // Create title label with the specified title
643729 titleLabel := webkit.NewLabel(title)
644730 if titleLabel == 0 {
@@ -646,7 +732,6 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr {
646732 }
647733
648734 webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")
649- webkit.LabelSetMaxWidthChars(titleLabel, 50)
650735 webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)
651736
652737 webkit.BoxAppend(titleBox, titleLabel)
@@ -852,3 +937,38 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
852937 return nil
853938 }
854939 }
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 {
4545 return mc
4646 }
4747
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+
4867 func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
4968 dataDir, err := config.GetDataDir()
5069 if err != nil {
@@ -67,7 +86,7 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
6786 cfg := &webkit.Config{
6887 Assets: app.assets,
6988 InitialURL: "dumb://homepage",
70- ZoomDefault: 1.0,
89+ ZoomDefault: app.config.DefaultZoom,
7190 EnableDeveloperExtras: true,
7291 DataDir: webkitData,
7392 CacheDir: webkitCache,
@@ -93,6 +112,7 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
93112 CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
94113 DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
95114 },
115+ Colors: buildColorPalettes(app.config.Appearance),
96116 }
97117
98118 return cfg, nil
@@ -124,6 +144,15 @@ func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
124144 app.workspace.RegisterNavigationHandler(view)
125145 }
126146
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+
127156 return pane, nil
128157 }
129158
M internal/app/browser/workspace_css.go +4 -0
@@ -117,6 +117,10 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
117117 font-weight: 500;
118118 }
119119
120+ .stacked-pane-favicon {
121+ margin-right: 4px;
122+ }
123+
120124 .stacked-pane-active {
121125 /* Active pane is fully visible */
122126 }
M internal/app/browser/workspace_pane_ops.go +1 -15
@@ -124,21 +124,7 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
124124 log.Printf("[workspace] failed to prepare blank pane: %v", err)
125125 }
126126
127- // Wait for about:blank to load before opening omnibox
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- })
127+ // Omnibox will auto-open on about:blank via client-side detection
142128 }
143129
144130 // 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 (
44 "context"
55 "encoding/json"
66 "log"
7- neturl "net/url"
8- "sort"
97 "strconv"
108 "strings"
119 "time"
@@ -133,6 +131,10 @@ func (h *Handler) Handle(payload string) {
133131 h.handleConsoleMessage(msg)
134132 case "request-webview-id":
135133 h.handleWebViewIDRequest(msg)
134+ case "get_search_shortcuts":
135+ h.handleGetSearchShortcuts(msg)
136+ case "get_color_palettes":
137+ h.handleGetColorPalettes(msg)
136138 }
137139 }
138140
@@ -273,45 +275,10 @@ func (h *Handler) handleQuery(msg Message) {
273275 return scheme + "://" + host + "/favicon.ico"
274276 }
275277
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-
283278 results := make([]suggestion, 0, limit)
284279 seen := make(map[string]struct{}, limit*2)
285280
286- // Shortcuts
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- }
281+ // Shortcuts intentionally omitted; rely on explicit prefix commands (e.g., gh:) or history results
315282
316283 // History
317284 if len(results) < limit {
@@ -339,7 +306,14 @@ func (h *Handler) handleQuery(msg Message) {
339306 if _, ok := seen[url]; ok {
340307 continue
341308 }
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})
343317 seen[url] = struct{}{}
344318 if len(results) >= limit {
345319 break
@@ -631,3 +605,64 @@ func (h *Handler) handleWebViewIDRequest(msg Message) {
631605 log.Printf("[messaging] Failed to send webview ID: %v", err)
632606 }
633607 }
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 (
1313 "net/http"
1414 "os"
1515 "path/filepath"
16+ "strings"
1617 "time"
1718
1819 "github.com/bnema/dumber/internal/config"
@@ -113,6 +114,12 @@ func (fc *FaviconCache) getFilename(faviconURL string) string {
113114
114115 // downloadFavicon downloads a favicon and saves it to cache
115116 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+
116123 filename := fc.getFilename(faviconURL)
117124 cachedPath := filepath.Join(fc.cacheDir, filename)
118125
@@ -177,9 +184,7 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
177184 if _, err := io.Copy(downloadFile, limitedBody); err != nil {
178185 return fmt.Errorf("failed to save favicon data: %w", err)
179186 }
180- if err := downloadFile.Close(); err != nil {
181- log.Printf("[favicon] failed to close download file: %v", err)
182- }
187+ // Note: downloadFile will be closed by the defer above, no need to close explicitly
183188
184189 // Detect if it's an ICO file and convert with ffmpeg if needed
185190 if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil {
M internal/config/config.go +48 -1
@@ -40,6 +40,8 @@ type Config struct {
4040 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
4141 // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom.
4242 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"`
4345 // Workspace defines workspace, pane, and tab handling behaviour.
4446 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace"`
4547 }
@@ -118,7 +120,20 @@ type AppearanceConfig struct {
118120 SerifFont string `mapstructure:"serif_font" yaml:"serif_font"`
119121 MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
120122 // 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"`
122137 }
123138
124139 // VideoAccelerationConfig holds video hardware acceleration preferences.
@@ -368,6 +383,9 @@ func NewManager() (*Manager, error) {
368383 if err := v.BindEnv("use_dom_zoom", "DUMBER_USE_DOM_ZOOM"); err != nil {
369384 return nil, fmt.Errorf("failed to bind DUMBER_USE_DOM_ZOOM: %w", err)
370385 }
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+ }
371389
372390 // Video acceleration environment variable bindings
373391 videoAccelEnvBindings := map[string]string{
@@ -665,6 +683,8 @@ func (m *Manager) setDefaults() {
665683 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
666684 m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
667685 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)
668688
669689 // Video acceleration defaults
670690 m.viper.SetDefault("video_acceleration.enable_vaapi", defaults.VideoAcceleration.EnableVAAPI)
@@ -701,6 +721,7 @@ func (m *Manager) setDefaults() {
701721 // Rendering defaults
702722 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
703723 m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom)
724+ m.viper.SetDefault("default_zoom", defaults.DefaultZoom)
704725
705726 // Workspace defaults
706727 m.viper.SetDefault("workspace.enable_zellij_controls", defaults.Workspace.EnableZellijControls)
@@ -752,6 +773,31 @@ func (m *Manager) ensurePersistedDefaults(cfg *Config) {
752773 logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile))
753774 }
754775
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+
755801 // createDefaultConfig creates a default configuration file.
756802 func (m *Manager) createDefaultConfig() error {
757803 configFile, err := GetConfigFile()
@@ -768,6 +814,7 @@ func (m *Manager) createDefaultConfig() error {
768814 defaultConfig := DefaultConfig()
769815
770816 // Marshal to JSON with proper indentation
817+ // Note: Go 1.20+ automatically sorts map keys in JSON output
771818 configData, err := json.MarshalIndent(defaultConfig, "", " ")
772819 if err != nil {
773820 return fmt.Errorf("failed to marshal default config: %w", err)
M internal/config/defaults.go +43 -41
@@ -60,24 +60,7 @@ func DefaultConfig() *Config {
6060 RetentionPeriodDays: defaultRetentionDays, // 1 year
6161 CleanupIntervalDays: 1, // daily cleanup
6262 },
63- SearchShortcuts: map[string]SearchShortcut{
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- },
63+ SearchShortcuts: GetDefaultSearchShortcuts(),
8164 Dmenu: DmenuConfig{
8265 MaxHistoryItems: defaultMaxHistoryItems,
8366 ShowVisitCount: true,
@@ -110,6 +93,24 @@ func DefaultConfig() *Config {
11093 SerifFont: "Fira Sans",
11194 MonospaceFont: "Fira Code",
11295 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+ },
113114 },
114115 VideoAcceleration: VideoAccelerationConfig{
115116 EnableVAAPI: true,
@@ -127,7 +128,7 @@ func DefaultConfig() *Config {
127128 DisableVP9Hardware: false, // Allow VP9 hardware for high res content
128129 VideoBufferSizeMB: 16, // Larger buffer for AV1 streams
129130 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 support
131+ 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
131132 AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res
132133 DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing)
133134 },
@@ -163,6 +164,7 @@ func DefaultConfig() *Config {
163164 },
164165 RenderingMode: RenderingModeGPU,
165166 UseDomZoom: false,
167+ DefaultZoom: 1.2, // 120% default zoom for better readability
166168 Workspace: WorkspaceConfig{
167169 EnableZellijControls: true,
168170 PaneMode: PaneModeConfig{
@@ -209,6 +211,10 @@ func DefaultConfig() *Config {
209211 // GetDefaultSearchShortcuts returns the default search shortcuts.
210212 func GetDefaultSearchShortcuts() map[string]SearchShortcut {
211213 return map[string]SearchShortcut{
214+ "ddg": {
215+ URL: "https://duckduckgo.com/?q=%s",
216+ Description: "DuckDuckGo search",
217+ },
212218 "g": {
213219 URL: "https://www.google.com/search?q=%s",
214220 Description: "Google search",
@@ -217,37 +223,33 @@ func GetDefaultSearchShortcuts() map[string]SearchShortcut {
217223 URL: "https://github.com/search?q=%s",
218224 Description: "GitHub search",
219225 },
220- "yt": {
221- URL: "https://www.youtube.com/results?search_query=%s",
222- Description: "YouTube search",
223- },
224- "w": {
225- URL: "https://en.wikipedia.org/wiki/%s",
226- Description: "Wikipedia search",
226+ "go": {
227+ URL: "https://pkg.go.dev/search?q=%s",
228+ Description: "Go package search",
227229 },
228- "ddg": {
229- URL: "https://duckduckgo.com/?q=%s",
230- Description: "DuckDuckGo search",
230+ "mdn": {
231+ URL: "https://developer.mozilla.org/en-US/search?q=%s",
232+ Description: "MDN Web Docs search",
231233 },
232- "so": {
233- URL: "https://stackoverflow.com/search?q=%s",
234- Description: "Stack Overflow search",
234+ "npm": {
235+ URL: "https://www.npmjs.com/search?q=%s",
236+ Description: "npm package search",
235237 },
236238 "r": {
237239 URL: "https://www.reddit.com/search?q=%s",
238240 Description: "Reddit search",
239241 },
240- "npm": {
241- URL: "https://www.npmjs.com/search?q=%s",
242- Description: "npm package search",
242+ "so": {
243+ URL: "https://stackoverflow.com/search?q=%s",
244+ Description: "Stack Overflow search",
243245 },
244- "go": {
245- URL: "https://pkg.go.dev/search?q=%s",
246- Description: "Go package search",
246+ "w": {
247+ URL: "https://en.wikipedia.org/wiki/%s",
248+ Description: "Wikipedia search",
247249 },
248- "mdn": {
249- URL: "https://developer.mozilla.org/en-US/search?q=%s",
250- Description: "MDN Web Docs search",
250+ "yt": {
251+ URL: "https://www.youtube.com/results?search_query=%s",
252+ Description: "YouTube search",
251253 },
252254 }
253255 }
M internal/db/history.sql.go +23 -1
@@ -73,6 +73,28 @@ func (q *Queries) GetHistory(ctx context.Context, limit int64) ([]History, error
7373 return items, nil
7474 }
7575
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+
7698 const GetHistoryWithOffset = `-- name: GetHistoryWithOffset :many
7799 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url
78100 FROM history
@@ -186,7 +208,7 @@ func (q *Queries) SearchHistory(ctx context.Context, column1 sql.NullString, col
186208 }
187209
188210 const UpdateHistoryFavicon = `-- name: UpdateHistoryFavicon :exec
189-UPDATE history
211+UPDATE history
190212 SET favicon_url = ?
191213 WHERE url = ?
192214 `
M internal/db/querier.go +1 -0
@@ -20,6 +20,7 @@ type Querier interface {
2020 DeleteZoomLevel(ctx context.Context, domain string) error
2121 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
2222 GetHistory(ctx context.Context, limit int64) ([]History, error)
23+ GetHistoryEntry(ctx context.Context, url string) (History, error)
2324 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
2425 GetShortcuts(ctx context.Context) ([]Shortcut, error)
2526 // 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
2727 LIMIT ?;
2828
2929 -- name: UpdateHistoryFavicon :exec
30-UPDATE history
30+UPDATE history
3131 SET favicon_url = ?
3232 WHERE url = ?;
3333
34+-- name: GetHistoryEntry :one
35+SELECT *
36+FROM history
37+WHERE url = ?
38+LIMIT 1;
39+
3440 -- name: DeleteHistory :exec
3541 DELETE FROM history
3642 WHERE id = ?;
M internal/services/browser.go +34 -4
@@ -446,7 +446,7 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er
446446 // GetZoomLevel retrieves the saved zoom level for a URL.
447447 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
448448 if url == "" {
449- return 1.0, nil
449+ return s.config.DefaultZoom, nil
450450 }
451451
452452 key := zoomKeyFromURL(url)
@@ -458,10 +458,10 @@ func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64,
458458 if err2 == nil {
459459 return zl, nil
460460 }
461- // No zoom setting found, return default
462- return 1.0, nil
461+ // No zoom setting found, return configured default
462+ return s.config.DefaultZoom, nil
463463 }
464- return 1.0, err
464+ return s.config.DefaultZoom, err
465465 }
466466
467467 return zoomLevel, nil
@@ -636,6 +636,11 @@ func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
636636 }
637637 }
638638
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+
639644 // handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database
640645 func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) {
641646 log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI)
@@ -663,4 +668,29 @@ func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI stri
663668 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
664669 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
665670 }
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+ }
666696 }
M pkg/webkit/settings.go +20 -0
@@ -48,11 +48,31 @@ type Config struct {
4848 // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.
4949 UseDomZoom bool
5050
51+ // Colors defines the light/dark palette tokens for the GUI.
52+ Colors ColorPalettes
53+
5154 // CreateWindow controls whether to create a window during WebView initialization.
5255 // Set to false when creating WebViews for workspace panes that will be reparented.
5356 CreateWindow bool
5457 }
5558
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+
5676 // CodecPreferencesConfig holds codec preferences for WebKit media playback
5777 type CodecPreferencesConfig struct {
5878 PreferredCodecs []string
M pkg/webkit/webview_cgo.go +6 -1
@@ -1305,7 +1305,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
13051305 log.Printf("[webkit] Set custom User-Agent: %s", cfg.CodecPreferences.CustomUserAgent)
13061306 } else if cfg.CodecPreferences.ForceAV1 {
13071307 // 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"
13091309 cAV1UA := C.CString(av1UA)
13101310 C.webkit_settings_set_user_agent(settings, (*C.gchar)(cAV1UA))
13111311 C.free(unsafe.Pointer(cAV1UA))
@@ -1844,6 +1844,7 @@ func (w *WebView) CreateRelatedView() *WebView {
18441844 VideoAcceleration: w.config.VideoAcceleration,
18451845 Memory: w.config.Memory,
18461846 CodecPreferences: w.config.CodecPreferences,
1847+ Colors: w.config.Colors,
18471848 CreateWindow: false,
18481849 }
18491850 related, err := NewWebViewWithRelated(cfg, w)
@@ -2060,6 +2061,8 @@ func (w *WebView) enableUserContentManager(cfg *Config) {
20602061 C.webkit_user_script_unref(schemeScript)
20612062 }
20622063
2064+ // Color palette injection now handled in main-world.ts via __PALETTE_JSON__ placeholder
2065+
20632066 // Add GUI bundle as user script at document-start (contains toast, omnibox, and controls)
20642067 if cfg != nil && cfg.Assets != nil {
20652068 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
22092212 log.Printf("[webkit] Building script with WebView %s, active state: %s", webViewIdStr, activeState)
22102213 script = strings.ReplaceAll(script, "\"__WEBVIEW_ACTIVE__\"", activeState)
22112214
2215+ // Color palettes and search shortcuts are now loaded via messaging bridge
2216+
22122217 log.Printf("[webkit] Loaded main-world script successfully, size: %d bytes", len(scriptBytes))
22132218 return script
22142219 }
M pkg/webkit/workspace_widgets_cgo.go +110 -0
@@ -33,6 +33,7 @@ extern gboolean goIdleCallback(uintptr_t handle);
3333 extern void goHoverCallback(uintptr_t handle);
3434 extern void goFocusEnterCallback(uintptr_t handle);
3535 extern void goFocusLeaveCallback(uintptr_t handle);
36+extern void goTitleBarClickCallback(uintptr_t handle);
3637
3738 // Main thread helpers implemented in thread_helpers.c
3839 void store_main_thread_id();
@@ -51,6 +52,26 @@ static gboolean remove_idle_callback(uintptr_t handle) {
5152 return g_idle_remove_by_data((gpointer)handle);
5253 }
5354
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+
5475 static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) {
5576 (void)controller; (void)x; (void)y;
5677 uintptr_t handle = (uintptr_t)user_data;
@@ -367,6 +388,26 @@ static void widget_get_allocation_modern(GtkWidget* widget, int* x, int* y, int*
367388 *height = gtk_widget_get_height(widget);
368389 }
369390 }
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+}
370411 */
371412 import "C"
372413
@@ -411,6 +452,11 @@ var (
411452 focusControllers = make(map[uintptr]uintptr)
412453 nextFocusID uintptr = 1
413454
455+ // Title bar click management
456+ titleBarMu sync.Mutex
457+ titleBarCallbacks = make(map[uintptr]func())
458+ nextTitleBarID uintptr = 1
459+
414460 mainThreadInitialized bool
415461 )
416462
@@ -862,6 +908,22 @@ func WidgetRemoveHoverHandler(widget uintptr, token uintptr) {
862908 C.widget_remove_controller((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.GtkEventController)(unsafe.Pointer(controller)))
863909 }
864910
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+
865927 // WidgetGetBounds returns the absolute bounds of the widget relative to the root window.
866928 func WidgetGetBounds(widget uintptr) (WidgetBounds, bool) {
867929 if widget == 0 || !widgetIsValid(widget) {
@@ -938,6 +1000,17 @@ func goHoverCallback(handle C.uintptr_t) {
9381000 }
9391001 }
9401002
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+
9411014 // GtkBox functions for stacked panes
9421015
9431016 // NewBox creates a new GtkBox widget with the specified orientation and spacing.
@@ -1238,3 +1311,40 @@ func WidgetGetAllocation(widget uintptr) WidgetAllocation {
12381311 Height: int(height),
12391312 }
12401313 }
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