1<!--
2 Omnibox Component
3
4 Main container for omnibox/find functionality using Svelte 5 runes
5-->
6<script lang="ts">
7 import { onMount, onDestroy } from 'svelte';
8 import OmniboxInput from './OmniboxInput.svelte';
9 import OmniboxSuggestions from './OmniboxSuggestions.svelte';
10 import OmniboxFind from './OmniboxFind.svelte';
11 import { omniboxStore } from './stores.svelte.ts';
12 import { omniboxBridge } from './messaging';
13
14 // Reactive state from store
15 let visible = $derived(omniboxStore.visible);
16 let mode = $derived(omniboxStore.mode);
17 let faded = $derived(omniboxStore.faded);
18
19 // Component refs
20 let boxElement: HTMLDivElement;
21 let blurLayerElement: HTMLDivElement;
22 let inputElement = $state<HTMLInputElement>();
23
24 // Responsive styling state
25 let responsiveStyles = $state({
26 width: 'min(90vw, 720px)',
27 padding: '8px 12px',
28 fontSize: '16px',
29 inputPadding: '10px 12px'
30 });
31
32 // Track theme mode derived from injected color-scheme module
33 let isDarkMode = $state(false);
34 let themeObserver: MutationObserver | null = null;
35
36 function syncTheme() {
37 if (typeof document === 'undefined') return;
38
39 const globalWindow = typeof window === 'undefined' ? undefined : window;
40
41 if (document.documentElement.classList.contains('dark')) {
42 isDarkMode = true;
43 return;
44 }
45
46 if (globalWindow?.matchMedia) {
47 try {
48 const prefersDark = globalWindow.matchMedia('(prefers-color-scheme: dark)').matches;
49 if (typeof prefersDark === 'boolean') {
50 isDarkMode = prefersDark;
51 return;
52 }
53 } catch {
54 // matchMedia might be unavailable in some environments
55 }
56 }
57
58 // Fallback to injected GTK preference flag if available
59 isDarkMode = Boolean((globalWindow as any)?.__dumber_gtk_prefers_dark);
60 }
61
62 function observeThemeChanges() {
63 if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
64 return;
65 }
66
67 if (themeObserver) {
68 themeObserver.disconnect();
69 }
70
71 themeObserver = new MutationObserver(syncTheme);
72 themeObserver.observe(document.documentElement, {
73 attributes: true,
74 attributeFilter: ['class']
75 });
76 }
77
78 // Handle click outside to close
79 function handleOverlayClick(event: MouseEvent) {
80 const target = event.target as HTMLElement;
81 if (target && boxElement && !boxElement.contains(target)) {
82 event.preventDefault();
83 event.stopPropagation();
84 omniboxStore.close();
85 }
86 }
87
88 // Handle box click to prevent propagation
89 function handleBoxClick(event: MouseEvent) {
90 event.stopPropagation();
91 }
92
93 // Handle mouse enter to focus input and clear fade
94 function handleMouseEnter() {
95 if (visible && inputElement) {
96 try {
97 inputElement.focus({ preventScroll: true });
98 } catch {
99 inputElement.focus();
100 }
101 omniboxStore.setFaded(false);
102 }
103 }
104
105 // Update responsive styles based on viewport width (handles zoom levels)
106 function updateResponsiveStyles() {
107 const vw = window.innerWidth;
108 const isSmall = vw < 640;
109 const isLarge = vw > 1920;
110
111 responsiveStyles = {
112 width: isSmall ? '95vw' : isLarge ? 'min(90vw, 840px)' : 'min(90vw, 720px)',
113 padding: isSmall ? '6px 8px' : '8px 12px',
114 fontSize: isSmall ? '14px' : isLarge ? '17px' : '16px',
115 inputPadding: isSmall ? '8px 10px' : '10px 12px'
116 };
117 }
118
119 // Focus input when component becomes visible and manage page event blocking
120 $effect(() => {
121 if (visible && inputElement) {
122 inputElement.focus();
123 // Enable keyboard event blocking in main world when omnibox opens
124 enablePageEventBlocking();
125 } else if (!visible) {
126 // Disable keyboard event blocking when omnibox closes
127 disablePageEventBlocking();
128 }
129 });
130
131 // Functions to control main-world event blocking
132 function enablePageEventBlocking() {
133 try {
134 // Send message to Go backend to enable keyboard blocking
135 if (window.webkit?.messageHandlers?.dumber) {
136 window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({
137 type: 'keyboard_blocking',
138 action: 'enable'
139 }));
140 }
141 } catch (error) {
142 console.warn('[omnibox] Failed to enable page event blocking:', error);
143 }
144 }
145
146 function disablePageEventBlocking() {
147 try {
148 // Send message to Go backend to disable keyboard blocking
149 if (window.webkit?.messageHandlers?.dumber) {
150 window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({
151 type: 'keyboard_blocking',
152 action: 'disable'
153 }));
154 }
155 } catch (error) {
156 console.warn('[omnibox] Failed to disable page event blocking:', error);
157 }
158 }
159
160
161 // Apply faded styling effect (matching original JS implementation)
162 // NOTE: to avoid text becoming blurry while animating, we animate the
163 // blur via an absolutely-positioned layer's opacity instead of
164 // animating `backdrop-filter` on the container. This keeps the text
165 // on a separate compositing layer and prevents browser antialiasing
166 // artifacts during transitions.
167 $effect(() => {
168 if (!boxElement || !inputElement || !blurLayerElement) return;
169
170 // Ensure subtle transitions are present on the container so
171 // background-color (and other cheap properties) animate smoothly.
172 try {
173 if (!boxElement.style.transition) {
174 boxElement.style.transition = 'background-color 120ms ease, border-color 120ms ease';
175 }
176 } catch (e) {
177 // defensive: ignore if setting styles fails for some reason
178 }
179
180 const surfaceMix = isDarkMode ? '55%' : '88%';
181 const inputMix = isDarkMode ? '52%' : '90%';
182 const borderMix = isDarkMode ? '62%' : '85%';
183 const blurMix = isDarkMode ? '22%' : '12%';
184
185 const applyFade = faded && mode === 'find';
186
187 const containerBackground = applyFade
188 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
189 : 'var(--dynamic-surface)';
190 const inputBackground = applyFade
191 ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)`
192 : 'var(--dynamic-bg)';
193 const borderColor = applyFade
194 ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, transparent)`
195 : 'var(--dynamic-border)';
196 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
197
198 boxElement.style.background = containerBackground;
199 boxElement.style.color = 'var(--dynamic-text)';
200 boxElement.style.setProperty('--dumber-border-color', borderColor);
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%)');
203
204 inputElement.style.background = inputBackground;
205 inputElement.style.color = 'var(--dynamic-text)';
206 inputElement.style.setProperty('--dumber-input-bg', inputBackground);
207 inputElement.style.setProperty('--dumber-input-border-base', borderColor);
208 inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');
209
210 blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);
211 blurLayerElement.style.background = 'var(--dumber-blur-color)';
212
213 let accentColor = 'var(--dynamic-accent)';
214 try {
215 const computedAccent = getComputedStyle(boxElement).getPropertyValue('--dynamic-accent');
216 if (computedAccent && computedAccent.trim()) {
217 accentColor = computedAccent.trim();
218 }
219 } catch {
220 // If computed styles fail, keep fallback accent color
221 }
222 inputElement.style.setProperty('--dumber-input-accent', accentColor);
223 boxElement.style.setProperty('--dumber-input-accent', 'var(--dynamic-muted)');
224
225 const isFocused = document.activeElement === inputElement;
226
227 if (!isFocused) {
228 inputElement.style.setProperty('--dumber-input-border-color', 'var(--dynamic-border)');
229 }
230
231 if (applyFade) {
232 blurLayerElement.style.opacity = '1';
233 } else {
234 blurLayerElement.style.opacity = '0';
235 }
236 });
237
238 // Global click handler to close omnibox when clicking anywhere outside
239 function handleGlobalClick(event: MouseEvent) {
240 if (!visible) return;
241
242 const target = event.target as HTMLElement;
243 const omniboxContainer = document.getElementById('dumber-omnibox-root');
244
245 // Close if clicking outside the omnibox container
246 if (omniboxContainer && !omniboxContainer.contains(target)) {
247 omniboxStore.close();
248 }
249 }
250
251
252
253 onMount(() => {
254 syncTheme();
255 observeThemeChanges();
256
257 console.log('🔧 Omnibox component mounted');
258
259 // Initialize responsive styles
260 updateResponsiveStyles();
261
262 // Add resize listener for responsive updates (handles zoom changes)
263 window.addEventListener('resize', updateResponsiveStyles);
264
265 // Add global click listener
266 document.addEventListener('click', handleGlobalClick, true);
267
268 try {
269 // Set up global API for Go bridge
270 window.__dumber_omnibox = {
271 setSuggestions: (suggestions) => {
272 try {
273 console.log('📋 setSuggestions called with:', suggestions?.length || 0, 'items');
274 omniboxBridge.setSuggestions(suggestions);
275 } catch (error) {
276 console.error('Error in setSuggestions:', error);
277 }
278 },
279 toggle: () => {
280 try {
281 console.log('🔄 Omnibox toggle called, current visible:', omniboxStore.visible);
282 omniboxStore.toggle();
283 console.log('🔄 After toggle, visible:', omniboxStore.visible);
284 } catch (error) {
285 console.error('Error in toggle:', error);
286 }
287 },
288 open: (mode, query) => {
289 try {
290 console.log('🚪 Omnibox open called with mode:', mode, 'query:', query);
291 omniboxStore.open(mode as any, query);
292 console.log('🚪 After open, visible:', omniboxStore.visible, 'mode:', omniboxStore.mode);
293 } catch (error) {
294 console.error('Error in open:', error);
295 }
296 },
297 close: () => {
298 try {
299 console.log('🚪 Omnibox close called');
300 omniboxStore.close();
301 } catch (error) {
302 console.error('Error in close:', error);
303 }
304 },
305 findQuery: (query) => {
306 try {
307 console.log('🔍 Find query called with:', query);
308 if (omniboxStore.mode !== 'find') {
309 omniboxStore.setMode('find');
310 }
311 if (!omniboxStore.visible) {
312 omniboxStore.setVisible(true);
313 }
314 omniboxStore.setInputValue(query || '');
315 // Find will be triggered by input value change
316 } catch (error) {
317 console.error('Error in findQuery:', error);
318 }
319 },
320 setActive: (active) => {
321 try {
322 console.log('🎯 Omnibox setActive called with:', active);
323 // For now, this is a no-op but maintains API compatibility
324 // In the future, this could control focus management or visibility
325 } catch (error) {
326 console.error('Error in setActive:', error);
327 }
328 }
329 };
330
331 console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
332
333 // Check for pending suggestions that arrived before API was ready
334 if ((window as any).__dumber_omnibox_pending_suggestions) {
335 console.log('🔄 [OMNIBOX] Processing pending suggestions');
336 const pending = (window as any).__dumber_omnibox_pending_suggestions;
337 omniboxBridge.setSuggestions(pending);
338 delete (window as any).__dumber_omnibox_pending_suggestions;
339 }
340
341 // Auto-open omnibox on about:blank pages
342 if (window.location.href === 'about:blank') {
343 console.log('🚀 Auto-opening omnibox for about:blank');
344 omniboxStore.open('omnibox');
345 }
346 } catch (error) {
347 console.error('❌ Failed to set up omnibox global API:', error);
348 }
349 });
350
351 onDestroy(() => {
352 // Cleanup resize listener
353 window.removeEventListener('resize', updateResponsiveStyles);
354
355 // Cleanup global click listener
356 document.removeEventListener('click', handleGlobalClick, true);
357
358 if (themeObserver) {
359 themeObserver.disconnect();
360 themeObserver = null;
361 }
362
363 // Reset omnibox state completely
364 omniboxStore.reset();
365
366 // Cleanup global API
367 if (window.__dumber_omnibox) {
368 delete window.__dumber_omnibox;
369 }
370 });
371</script>
372
373<!-- Overlay container -->
374<div
375 class="fixed inset-0 z-[2147483647]"
376 style="display: {visible ? 'block' : 'none'};
377 position: fixed !important;
378 top: 0 !important;
379 left: 0 !important;
380 right: 0 !important;
381 bottom: 0 !important;
382 z-index: 2147483647 !important;
383 pointer-events: none !important;
384 margin: 0 !important;
385 padding: 0 !important;
386 isolation: isolate;"
387 onmousedown={handleOverlayClick}
388 role="presentation"
389>
390 <!-- Main omnibox container -->
391 <div
392 bind:this={boxElement}
393 class="dumber-omnibox-container omnibox-base terminal-omnibox"
394 style="position: relative !important;
395 left: 50% !important;
396 transform: translateX(-50%) !important;
397 margin-top: 20vh !important;
398 margin-left: 0 !important;
399 margin-right: 0 !important;
400 width: {responsiveStyles.width};
401 padding: {responsiveStyles.padding};
402 font-family: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', Menlo, monospace;
403 pointer-events: auto !important;
404 box-sizing: border-box !important;
405 background: var(--dumber-omnibox-surface, color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%));
406 color: var(--dynamic-text);
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);
413 --dumber-border-color: var(--dynamic-border);"
414 onmousedown={handleBoxClick}
415 onmouseenter={handleMouseEnter}
416 role="dialog"
417 tabindex="-1"
418 aria-modal="true"
419 aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'}
420 >
421 <!-- Blur layer: absolute full-cover element providing backdrop blur.
422 We animate its opacity rather than the container's backdrop-filter
423 to avoid causing text to blur during transitions. -->
424 <div
425 bind:this={blurLayerElement}
426 class="dumber-omnibox-blur-layer"
427 aria-hidden="true"
428 style="position: absolute; inset: 0; pointer-events: none; border-radius: 0; opacity: 0;"
429 ></div>
430 <div class="dumber-omnibox-content">
431 <!-- Input component -->
432 <OmniboxInput bind:inputElement {responsiveStyles} />
433
434 <!-- Content based on mode -->
435 {#if mode === 'omnibox'}
436 <OmniboxSuggestions />
437 {:else if mode === 'find'}
438 <OmniboxFind />
439 {/if}
440 </div>
441 </div>
442</div>
443
444<style>
445 .dumber-omnibox-container {
446 background: var(--dumber-omnibox-surface, color-mix(in srgb, var(--dynamic-bg) 88%, var(--dynamic-surface) 12%));
447 color: var(--dynamic-text);
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);
456 }
457
458 .dumber-omnibox-container:focus-within {
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);
465 }
466
467 :global(.dumber-omnibox-container input) {
468 background: var(--dumber-input-bg, color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%));
469 color: var(--dynamic-text);
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;
481 }
482
483 :global(.dumber-omnibox-container input::placeholder) {
484 color: var(--dynamic-muted);
485 letter-spacing: 0.08em;
486 text-transform: uppercase;
487 }
488
489 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
490 .dumber-omnibox-blur-layer {
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;
495 will-change: opacity;
496 z-index: 0;
497 mix-blend-mode: normal;
498 border-radius: 0;
499 }
500
501 /* Ensure the actual content (input, suggestions) sits above the blur layer */
502 .dumber-omnibox-content {
503 position: relative;
504 z-index: 1;
505 /* promote to its own layer to avoid being rasterized with the blur */
506 transform: translateZ(0);
507 backface-visibility: hidden;
508 }
509 /* Additional custom styles if needed */
510</style>