detached bce8d5b

refactor(webui): consolidate vite configs and remove bootstrapGUI dependency

commit
bce8d5b6433dec111dc63a8d006013899a5452ff
author
brice · · 2025-12-16 18:31 UTC
committer
brice · · 2025-12-16 17:02 UTC
parent
38d2b5d
signature
unsigned
42 files · +87 -7201
M Makefile +4 -4
@@ -36,10 +36,10 @@ build: build-frontend ## Build the application (pure Go, no CGO)
3636 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
3737 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
3838
39-build-frontend: ## Build Svelte GUI with Tailwind CSS and main-world script
40- @echo "Building Svelte GUI with Tailwind CSS..."
39+build-frontend: ## Build homepage and blocked pages
40+ @echo "Building webui pages (homepage + blocked)..."
4141 @cd webui && npm install --silent && npm run build
42- @echo "GUI build complete"
42+ @echo "Frontend build complete"
4343
4444 build-quick: ## Build without frontend (faster for backend development)
4545 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
@@ -108,7 +108,7 @@ clean: ## Clean build artifacts
108108 rm -rf $(DIST_DIR)
109109 rm -f $(BINARY_NAME)
110110 rm -rf webui/dist webui/node_modules
111- rm -f assets/webui/gui.min.js assets/webui/homepage.min.js assets/webui/homepage.css assets/webui/main-world.min.js assets/webui/color-scheme.js
111+ rm -f assets/webui/homepage.min.js assets/webui/blocked.min.js assets/webui/index.html assets/webui/blocked.html assets/webui/style.css
112112 rm -f coverage.out coverage.html
113113 go clean -cache
114114 go clean -testcache
M webui/package.json +7 -13
@@ -4,21 +4,15 @@
44 "private": true,
55 "type": "module",
66 "scripts": {
7- "build": "npm run build:color-scheme && npm run build:gui && npm run build:homepage && npm run build:blocked && npm run build:main-world",
8- "build:gui": "vite build --config vite.config.gui.ts",
9- "build:homepage": "vite build --config vite.config.homepage.ts",
10- "build:blocked": "vite build --config vite.config.blocked.ts",
11- "build:color-scheme": "vite build --config vite.config.color-scheme.ts",
12- "build:main-world": "vite build --config vite.config.main-world.ts",
13- "dev": "npm run build:gui -- --watch",
14- "dev:homepage": "npm run build:homepage -- --watch",
15- "dev:blocked": "npm run build:blocked -- --watch",
16- "dev:color-scheme": "npm run build:color-scheme -- --watch",
17- "dev:main-world": "npm run build:main-world -- --watch",
7+ "build": "VITE_PAGE=homepage vite build && VITE_PAGE=blocked vite build",
8+ "build:homepage": "VITE_PAGE=homepage vite build",
9+ "build:blocked": "VITE_PAGE=blocked vite build",
10+ "dev": "VITE_PAGE=homepage vite build --watch",
11+ "dev:homepage": "VITE_PAGE=homepage vite build --watch",
12+ "dev:blocked": "VITE_PAGE=blocked vite build --watch",
1813 "check": "svelte-check",
1914 "lint": "eslint src --fix",
20- "fmt": "prettier --write .",
21- "generate:types": "node ../scripts/generate-types.js"
15+ "fmt": "prettier --write ."
2216 },
2317 "devDependencies": {
2418 "@sveltejs/vite-plugin-svelte": "^4.0.0",
D webui/src/components/Footer.svelte +0 -80
@@ -1,80 +0,0 @@
1-<script lang="ts">
2- const openGithub = () => {
3- window.open("https://github.com/bnema/dumber", "_blank");
4- };
5-</script>
6-
7-<footer class="footer">
8- <div class="footer-content">
9- <div class="footer-text">
10- <span class="footer-name">Dumber Browser</span>
11- <span class="footer-dot">•</span>
12- <button class="footer-link" onclick={openGithub}>
13- View on GitHub
14- </button>
15- </div>
16- </div>
17-</footer>
18-
19-<style>
20- .footer {
21- margin-top: 1.5rem;
22- width: 100%;
23- border-top: 1px solid var(--dynamic-border);
24- background: color-mix(in srgb, var(--dynamic-bg) 92%, var(--dynamic-surface) 8%);
25- }
26-
27- .footer-content {
28- display: flex;
29- justify-content: center;
30- align-items: center;
31- padding: 0.75rem 0;
32- }
33-
34- .footer-text {
35- display: flex;
36- align-items: center;
37- gap: 0.75rem;
38- font-size: 0.75rem;
39- text-transform: uppercase;
40- letter-spacing: 0.12em;
41- color: var(--dynamic-muted);
42- }
43-
44- .footer-name {
45- color: var(--dynamic-text);
46- font-weight: 600;
47- }
48-
49- .footer-dot {
50- color: var(--dynamic-border);
51- }
52-
53- .footer-link {
54- background: none;
55- border: none;
56- color: var(--dynamic-muted);
57- text-decoration: none;
58- cursor: pointer;
59- font-size: 0.85rem;
60- padding: 0;
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;
68- }
69-
70- .footer-link:hover {
71- color: var(--dynamic-text);
72- letter-spacing: 0.16em;
73- }
74-
75- .footer-link:focus {
76- outline: 1px solid var(--dynamic-border);
77- outline-offset: 2px;
78- border-radius: 0;
79- }
80-</style>
D webui/src/components/omnibox/Omnibox.svelte +0 -560
@@ -1,560 +0,0 @@
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- // Ready state - prevents flash before initialization
20- let isReady = $state(false);
21-
22- // Component refs
23- let boxElement: HTMLDivElement;
24- let blurLayerElement: HTMLDivElement;
25- let inputElement = $state<HTMLInputElement>();
26-
27- // Responsive styling state
28- let responsiveStyles = $state({
29- width: 'min(90vw, 720px)',
30- padding: '8px 12px',
31- fontSize: '16px',
32- inputPadding: '10px 12px'
33- });
34-
35- // Track theme mode derived from injected color-scheme module
36- let isDarkMode = $state(false);
37- let themeObserver: MutationObserver | null = null;
38-
39- function syncTheme() {
40- if (typeof document === 'undefined') return;
41-
42- const globalWindow = typeof window === 'undefined' ? undefined : window;
43-
44- if (document.documentElement.classList.contains('dark')) {
45- isDarkMode = true;
46- return;
47- }
48-
49- if (globalWindow?.matchMedia) {
50- try {
51- const prefersDark = globalWindow.matchMedia('(prefers-color-scheme: dark)').matches;
52- if (typeof prefersDark === 'boolean') {
53- isDarkMode = prefersDark;
54- return;
55- }
56- } catch {
57- // matchMedia might be unavailable in some environments
58- }
59- }
60-
61- // Fallback to injected GTK preference flag if available
62- isDarkMode = Boolean((globalWindow as any)?.__dumber_gtk_prefers_dark);
63- }
64-
65- function observeThemeChanges() {
66- if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
67- return;
68- }
69-
70- if (themeObserver) {
71- themeObserver.disconnect();
72- }
73-
74- themeObserver = new MutationObserver(syncTheme);
75- themeObserver.observe(document.documentElement, {
76- attributes: true,
77- attributeFilter: ['class']
78- });
79- }
80-
81- // Handle click outside to close
82- function handleOverlayClick(event: MouseEvent) {
83- const target = event.target as HTMLElement;
84- if (target && boxElement && !boxElement.contains(target)) {
85- event.preventDefault();
86- event.stopPropagation();
87- omniboxStore.close();
88- }
89- }
90-
91- // Handle box click to prevent propagation
92- function handleBoxClick(event: MouseEvent) {
93- event.stopPropagation();
94- }
95-
96- // Handle mouse enter to focus input and clear fade
97- function handleMouseEnter() {
98- if (visible && inputElement) {
99- try {
100- inputElement.focus({ preventScroll: true });
101- } catch {
102- inputElement.focus();
103- }
104- omniboxStore.setFaded(false);
105- }
106- }
107-
108- // Update responsive styles based on viewport width (handles zoom levels)
109- function updateResponsiveStyles() {
110- const vw = window.innerWidth;
111- const isSmall = vw < 640;
112- const isLarge = vw > 1920;
113-
114- responsiveStyles = {
115- width: isSmall ? '95vw' : isLarge ? 'min(90vw, 840px)' : 'min(90vw, 720px)',
116- padding: isSmall ? '6px 8px' : '8px 12px',
117- fontSize: isSmall ? '14px' : isLarge ? '17px' : '16px',
118- inputPadding: isSmall ? '8px 10px' : '10px 12px'
119- };
120- }
121-
122- // Focus input when component becomes visible and manage page event blocking
123- $effect(() => {
124- if (visible && inputElement) {
125- inputElement.focus();
126- // Enable keyboard event blocking in main world when omnibox opens
127- enablePageEventBlocking();
128-
129- // Fetch initial history if opening in omnibox mode with empty input
130- if (mode === 'omnibox' && omniboxStore.inputValue === '') {
131- omniboxBridge.fetchInitialHistory();
132- }
133- } else if (!visible) {
134- // Disable keyboard event blocking when omnibox closes
135- disablePageEventBlocking();
136- }
137- });
138-
139- // Functions to control main-world event blocking
140- function enablePageEventBlocking() {
141- try {
142- // Send message to Go backend via bridge (isolated world → main world → Go)
143- omniboxBridge.postMessage({
144- type: 'keyboard_blocking',
145- action: 'enable'
146- });
147- console.log('[omnibox] Sent enable keyboard blocking message');
148- } catch (error) {
149- console.error('[omnibox] Failed to enable page event blocking:', error);
150- }
151- }
152-
153- function disablePageEventBlocking() {
154- try {
155- // Send message to Go backend via bridge (isolated world → main world → Go)
156- omniboxBridge.postMessage({
157- type: 'keyboard_blocking',
158- action: 'disable'
159- });
160- console.log('[omnibox] Sent disable keyboard blocking message');
161- } catch (error) {
162- console.error('[omnibox] Failed to disable page event blocking:', error);
163- }
164- }
165-
166-
167- // Apply faded styling effect (matching original JS implementation)
168- // NOTE: to avoid text becoming blurry while animating, we animate the
169- // blur via an absolutely-positioned layer's opacity instead of
170- // animating `backdrop-filter` on the container. This keeps the text
171- // on a separate compositing layer and prevents browser antialiasing
172- // artifacts during transitions.
173- $effect(() => {
174- if (!boxElement || !inputElement || !blurLayerElement) return;
175-
176- // Ensure subtle transitions are present on the container so
177- // background-color (and other cheap properties) animate smoothly.
178- try {
179- if (!boxElement.style.transition) {
180- boxElement.style.transition = 'background-color 120ms ease, border-color 120ms ease';
181- }
182- } catch (e) {
183- // defensive: ignore if setting styles fails for some reason
184- }
185-
186- const surfaceMix = isDarkMode ? '55%' : '88%';
187- const blurMix = isDarkMode ? '22%' : '12%';
188-
189- const applyFade = faded && mode === 'find';
190-
191- // GTK4-style: Container is slightly raised (use surface)
192- const containerBackground = applyFade
193- ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
194- : 'var(--dynamic-surface)';
195-
196- // GTK4-style: Input is recessed (darker background)
197- const inputBackground = applyFade
198- ? isDarkMode
199- ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 85%, black 15%) ${surfaceMix}, transparent)`
200- : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 95%, black 5%) ${surfaceMix}, transparent)`
201- : isDarkMode
202- ? 'color-mix(in srgb, var(--dynamic-bg) 85%, black 15%)'
203- : 'color-mix(in srgb, var(--dynamic-bg) 95%, black 5%)';
204-
205- // GTK4-style: Use palette border directly, slightly darker for input
206- const containerBorderColor = applyFade
207- ? `color-mix(in srgb, var(--dynamic-border) ${surfaceMix}, transparent)`
208- : 'var(--dynamic-border)';
209-
210- const inputBorderColor = applyFade
211- ? isDarkMode
212- ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 80%, black 20%) ${surfaceMix}, transparent)`
213- : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 85%, black 15%) ${surfaceMix}, transparent)`
214- : isDarkMode
215- ? 'color-mix(in srgb, var(--dynamic-border) 80%, black 20%)'
216- : 'color-mix(in srgb, var(--dynamic-border) 85%, black 15%)';
217-
218- const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
219-
220- boxElement.style.background = containerBackground;
221- boxElement.style.color = 'var(--dynamic-text)';
222- boxElement.style.setProperty('--dumber-border-color', containerBorderColor);
223- // Force the omnibox to use our neutral surfaces so it stays in sync with the shell palette
224- boxElement.style.setProperty('--dumber-omnibox-surface', containerBackground);
225-
226- inputElement.style.background = inputBackground;
227- inputElement.style.color = 'var(--dynamic-text)';
228- inputElement.style.setProperty('--dumber-input-bg', inputBackground);
229- inputElement.style.setProperty('--dumber-input-border-base', inputBorderColor);
230- inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');
231-
232- blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);
233- blurLayerElement.style.background = 'var(--dumber-blur-color)';
234-
235- let accentColor = 'var(--dynamic-accent)';
236- try {
237- const computedAccent = getComputedStyle(boxElement).getPropertyValue('--dynamic-accent');
238- if (computedAccent && computedAccent.trim()) {
239- accentColor = computedAccent.trim();
240- }
241- } catch {
242- // If computed styles fail, keep fallback accent color
243- }
244- inputElement.style.setProperty('--dumber-input-accent', accentColor);
245- boxElement.style.setProperty('--dumber-input-accent', 'var(--dynamic-muted)');
246-
247- const isFocused = document.activeElement === inputElement;
248-
249- if (!isFocused) {
250- inputElement.style.setProperty('--dumber-input-border-color', 'var(--dynamic-border)');
251- }
252-
253- if (applyFade) {
254- blurLayerElement.style.opacity = '1';
255- } else {
256- blurLayerElement.style.opacity = '0';
257- }
258- });
259-
260- // Global click handler to close omnibox when clicking anywhere outside
261- function handleGlobalClick(event: MouseEvent) {
262- if (!visible) return;
263-
264- const target = event.target as HTMLElement;
265- const omniboxContainer = document.getElementById('dumber-omnibox-root');
266-
267- // Close if clicking outside the omnibox container
268- if (omniboxContainer && !omniboxContainer.contains(target)) {
269- omniboxStore.close();
270- }
271- }
272-
273-
274-
275- onMount(async () => {
276- syncTheme();
277- observeThemeChanges();
278-
279- console.log('🔧 Omnibox component mounted');
280-
281- // Initialize responsive styles
282- updateResponsiveStyles();
283-
284- // Add resize listener for responsive updates (handles zoom changes)
285- window.addEventListener('resize', updateResponsiveStyles);
286-
287- // Add global click listener
288- document.addEventListener('click', handleGlobalClick, true);
289-
290- // Load search shortcuts and favorites on mount
291- try {
292- await Promise.all([
293- omniboxBridge.fetchSearchShortcuts(),
294- omniboxBridge.fetchFavorites()
295- ]);
296- console.log('✅ Search shortcuts and favorites loaded on mount');
297- } catch (error) {
298- console.warn('Failed to load initial data:', error);
299- }
300-
301- try {
302- // Set up global API for Go bridge
303- window.__dumber_omnibox = {
304- setSuggestions: (suggestions) => {
305- try {
306- console.log('📋 setSuggestions called with:', suggestions?.length || 0, 'items');
307- omniboxBridge.setSuggestions(suggestions);
308- } catch (error) {
309- console.error('Error in setSuggestions:', error);
310- }
311- },
312- toggle: () => {
313- try {
314- console.log('🔄 Omnibox toggle called, current visible:', omniboxStore.visible);
315- omniboxStore.toggle();
316- console.log('🔄 After toggle, visible:', omniboxStore.visible);
317- } catch (error) {
318- console.error('Error in toggle:', error);
319- }
320- },
321- open: (mode, query) => {
322- try {
323- console.log('🚪 Omnibox open called with mode:', mode, 'query:', query);
324- omniboxStore.open(mode as any, query);
325- console.log('🚪 After open, visible:', omniboxStore.visible, 'mode:', omniboxStore.mode);
326- } catch (error) {
327- console.error('Error in open:', error);
328- }
329- },
330- close: () => {
331- try {
332- console.log('🚪 Omnibox close called');
333- omniboxStore.close();
334- } catch (error) {
335- console.error('Error in close:', error);
336- }
337- },
338- findQuery: (query) => {
339- try {
340- console.log('🔍 Find query called with:', query);
341- if (omniboxStore.mode !== 'find') {
342- omniboxStore.setMode('find');
343- }
344- if (!omniboxStore.visible) {
345- omniboxStore.setVisible(true);
346- }
347- omniboxStore.setInputValue(query || '');
348- // Find will be triggered by input value change
349- } catch (error) {
350- console.error('Error in findQuery:', error);
351- }
352- },
353- setActive: (active) => {
354- try {
355- console.log('🎯 Omnibox setActive called with:', active);
356- // For now, this is a no-op but maintains API compatibility
357- // In the future, this could control focus management or visibility
358- } catch (error) {
359- console.error('Error in setActive:', error);
360- }
361- },
362- setInlineSuggestion: (url) => {
363- try {
364- console.log('[INLINE] window.__dumber_omnibox.setInlineSuggestion called:', url);
365- omniboxBridge.setInlineSuggestion(url);
366- } catch (error) {
367- console.error('Error in setInlineSuggestion:', error);
368- }
369- }
370- };
371-
372- console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
373-
374- // Check for pending suggestions that arrived before API was ready
375- if ((window as any).__dumber_omnibox_pending_suggestions) {
376- console.log('🔄 [OMNIBOX] Processing pending suggestions');
377- const pending = (window as any).__dumber_omnibox_pending_suggestions;
378- omniboxBridge.setSuggestions(pending);
379- delete (window as any).__dumber_omnibox_pending_suggestions;
380- }
381- } catch (error) {
382- console.error('❌ Failed to set up omnibox global API:', error);
383- }
384-
385- // Mark as ready - removes initial hiding styles
386- isReady = true;
387- console.log('✅ Omnibox fully initialized and ready');
388- });
389-
390- onDestroy(() => {
391- // Cleanup resize listener
392- window.removeEventListener('resize', updateResponsiveStyles);
393-
394- // Cleanup global click listener
395- document.removeEventListener('click', handleGlobalClick, true);
396-
397- if (themeObserver) {
398- themeObserver.disconnect();
399- themeObserver = null;
400- }
401-
402- // Reset omnibox state completely
403- omniboxStore.reset();
404-
405- // Cleanup global API
406- if (window.__dumber_omnibox) {
407- delete window.__dumber_omnibox;
408- }
409- });
410-</script>
411-
412-<!-- Overlay container -->
413-<div
414- class="fixed inset-0 z-[2147483647]"
415- style="display: {visible ? 'block' : 'none'};
416- position: fixed !important;
417- top: 0 !important;
418- left: 0 !important;
419- right: 0 !important;
420- bottom: 0 !important;
421- z-index: {isReady ? '2147483647' : '-9999'} !important;
422- visibility: {isReady ? 'visible' : 'hidden'} !important;
423- opacity: {isReady ? '1' : '0'} !important;
424- pointer-events: {isReady ? 'none' : 'none'} !important;
425- margin: 0 !important;
426- padding: 0 !important;
427- isolation: isolate;"
428- onmousedown={handleOverlayClick}
429- role="presentation"
430->
431- <!-- Main omnibox container -->
432- <div
433- bind:this={boxElement}
434- class="dumber-omnibox-container omnibox-base terminal-omnibox"
435- style="position: relative !important;
436- left: 50% !important;
437- transform: translateX(-50%) !important;
438- margin-top: 20vh !important;
439- margin-left: 0 !important;
440- margin-right: 0 !important;
441- width: {responsiveStyles.width};
442- padding: {responsiveStyles.padding};
443- font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
444- pointer-events: auto !important;
445- box-sizing: border-box !important;
446- background: var(--dumber-omnibox-surface, var(--dynamic-surface));
447- color: var(--dynamic-text);
448- border: 1px solid var(--dumber-border-color, var(--dynamic-border));
449- border-radius: 3px;
450- box-shadow:
451- 0 4px 12px rgba(0, 0, 0, 0.2),
452- 0 2px 6px rgba(0, 0, 0, 0.15),
453- 0 1px 3px rgba(0, 0, 0, 0.1),
454- inset 0 1px 0 rgba(255, 255, 255, 0.04);
455- --dumber-border-color: var(--dynamic-border);"
456- onmousedown={handleBoxClick}
457- onmouseenter={handleMouseEnter}
458- role="dialog"
459- tabindex="-1"
460- aria-modal="true"
461- aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'}
462- >
463- <!-- Blur layer: absolute full-cover element providing backdrop blur.
464- We animate its opacity rather than the container's backdrop-filter
465- to avoid causing text to blur during transitions. -->
466- <div
467- bind:this={blurLayerElement}
468- class="dumber-omnibox-blur-layer"
469- aria-hidden="true"
470- style="position: absolute; inset: 0; pointer-events: none; border-radius: 0; opacity: 0;"
471- ></div>
472- <div class="dumber-omnibox-content">
473- <!-- Input component -->
474- <OmniboxInput bind:inputElement {responsiveStyles} />
475-
476- <!-- Content based on mode -->
477- {#if mode === 'omnibox'}
478- <OmniboxSuggestions />
479- {:else if mode === 'find'}
480- <OmniboxFind />
481- {/if}
482- </div>
483- </div>
484-</div>
485-
486-<style>
487- .dumber-omnibox-container {
488- background: var(--dumber-omnibox-surface, var(--dynamic-surface));
489- color: var(--dynamic-text);
490- border: 1px solid var(--dumber-border-color, var(--dynamic-border));
491- transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
492- border-radius: 3px;
493- box-shadow:
494- 0 4px 12px rgba(0, 0, 0, 0.2),
495- 0 2px 6px rgba(0, 0, 0, 0.15),
496- 0 1px 3px rgba(0, 0, 0, 0.1),
497- inset 0 1px 0 rgba(255, 255, 255, 0.04);
498- }
499-
500- .dumber-omnibox-container:focus-within {
501- border-color: var(--dynamic-accent);
502- box-shadow:
503- 0 6px 16px rgba(0, 0, 0, 0.24),
504- 0 3px 8px rgba(0, 0, 0, 0.18),
505- 0 2px 4px rgba(0, 0, 0, 0.12),
506- inset 0 1px 0 rgba(255, 255, 255, 0.06),
507- 0 0 0 1px var(--dynamic-accent);
508- }
509-
510- :global(.dumber-omnibox-container input) {
511- background: var(--dumber-input-bg, var(--dynamic-bg));
512- color: var(--dynamic-text);
513- border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
514- border-radius: 2px;
515- box-shadow:
516- inset 0 1px 2px rgba(0, 0, 0, 0.15),
517- inset 0 0 0 1px rgba(0, 0, 0, 0.03);
518- transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
519- font-family:
520- "Fira Sans",
521- system-ui,
522- -apple-system,
523- "Segoe UI",
524- "Ubuntu",
525- "Cantarell",
526- sans-serif;
527- font-size: inherit;
528- letter-spacing: normal;
529- text-transform: none;
530- }
531-
532- :global(.dumber-omnibox-container input::placeholder) {
533- color: var(--dynamic-muted);
534- letter-spacing: normal;
535- text-transform: none;
536- }
537-
538- /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
539- /* GTK4-style: Minimal blur for faded state */
540- .dumber-omnibox-blur-layer {
541- backdrop-filter: blur(0.5px) saturate(102%);
542- -webkit-backdrop-filter: blur(0.5px) saturate(102%);
543- background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 8%, transparent));
544- transition: opacity 140ms ease;
545- will-change: opacity;
546- z-index: 0;
547- mix-blend-mode: normal;
548- border-radius: 0;
549- }
550-
551- /* Ensure the actual content (input, suggestions) sits above the blur layer */
552- .dumber-omnibox-content {
553- position: relative;
554- z-index: 1;
555- /* promote to its own layer to avoid being rasterized with the blur */
556- transform: translateZ(0);
557- backface-visibility: hidden;
558- }
559- /* Additional custom styles if needed */
560-</style>
D webui/src/components/omnibox/OmniboxFind.svelte +0 -135
@@ -1,135 +0,0 @@
1-<!--
2- Omnibox Find Component
3-
4- Displays find results and match navigation
5--->
6-<script lang="ts">
7- import { omniboxStore } from './stores.svelte.ts';
8- import { revealMatch } from './find';
9-
10- // Reactive state
11- let matches = $derived(omniboxStore.matches);
12- let selectedIndex = $derived(omniboxStore.selectedIndex);
13- let hasContent = $state(false);
14-
15- // Update hasContent when matches change
16- $effect(() => {
17- hasContent = omniboxStore.mode === 'find' && matches.length > 0;
18- });
19-
20- // Computed total count
21- let totalMatches = $derived(matches.length);
22-
23- // Handle match item mouse enter
24- function handleItemMouseEnter(index: number) {
25- omniboxStore.setSelectedIndex(index);
26- omniboxStore.setFaded(true);
27- scrollToSelection();
28- }
29-
30- // Handle match item click
31- function handleItemClick(index: number) {
32- omniboxStore.setSelectedIndex(index);
33- revealMatch(index);
34- omniboxStore.close();
35- }
36-
37- // Scroll list to show selected item
38- function scrollToSelection() {
39- const selectedItem = document.getElementById(`find-item-${selectedIndex}`);
40- if (selectedItem && selectedItem.scrollIntoView) {
41- try {
42- selectedItem.scrollIntoView({ block: 'nearest' });
43- } catch {
44- selectedItem.scrollIntoView();
45- }
46- }
47- }
48-
49- // Watch for selection changes to scroll
50- $effect(() => {
51- if (selectedIndex >= 0) {
52- scrollToSelection();
53- }
54- });
55-</script>
56-
57-<div id="find-list" class="find-list" role="listbox" aria-label="Find results">
58- <!-- Header with match count -->
59- <div class="find-header">
60- {#if totalMatches > 0}
61- {totalMatches} match{totalMatches === 1 ? '' : 'es'}
62- {:else}
63- No matches
64- {/if}
65- </div>
66-
67- <!-- Match results -->
68- {#if hasContent}
69- {#each matches as match, index (index)}
70- {@const isSelected = index === selectedIndex}
71-
72- <div
73- id="find-item-{index}"
74- class={isSelected ? 'find-item selected' : 'find-item'}
75- role="option"
76- tabindex="-1"
77- aria-selected={isSelected}
78- onmouseenter={() => handleItemMouseEnter(index)}
79- onclick={() => handleItemClick(index)}
80- onkeydown={(e) => e.key === 'Enter' && handleItemClick(index)}
81- >
82- <!-- Match context -->
83- <div class="find-context">
84- {match.context || ''}
85- </div>
86- </div>
87- {/each}
88- {/if}
89-</div>
90-
91-<style>
92- .find-list {
93- margin-top: 0.75rem;
94- max-height: 50vh;
95- overflow-y: auto;
96- border-top: 1px solid var(--dynamic-border);
97- background: color-mix(in srgb, var(--dynamic-bg) 94%, var(--dynamic-surface) 6%);
98- }
99-
100- .find-header {
101- padding: 0.65rem 0.85rem;
102- font-size: 0.7rem;
103- text-transform: uppercase;
104- letter-spacing: 0.08em;
105- color: var(--dynamic-muted);
106- border-bottom: 1px dashed var(--dynamic-border);
107- }
108-
109- .find-item {
110- padding: 0.65rem 0.85rem;
111- border-bottom: 1px dashed var(--dynamic-border);
112- cursor: pointer;
113- transition: background-color 120ms ease, color 120ms ease;
114- }
115-
116- .find-item:last-child {
117- border-bottom: none;
118- }
119-
120- .find-item.selected,
121- .find-item:hover,
122- .find-item:focus-visible {
123- background: color-mix(in srgb, var(--dynamic-bg) 75%, var(--dynamic-surface) 25%);
124- color: var(--dynamic-text);
125- outline: none;
126- }
127-
128- .find-context {
129- color: var(--dynamic-text);
130- font-size: 0.75rem;
131- white-space: nowrap;
132- overflow: hidden;
133- text-overflow: ellipsis;
134- }
135-</style>
D webui/src/components/omnibox/OmniboxInput.svelte +0 -654
@@ -1,654 +0,0 @@
1-<!--
2- Omnibox Input Component
3-
4- Input field with keyboard handling and debounced search
5--->
6-<script lang="ts">
7- import { omniboxStore } from './stores.svelte.ts';
8- import { debouncedQuery, debouncedFind, debouncedPrefixQuery, omniboxBridge } from './messaging';
9- import { findInPage } from './find';
10-
11- // Reactive state
12- let mode = $derived(omniboxStore.mode);
13- let viewMode = $derived(omniboxStore.viewMode);
14- let inputValue = $derived(omniboxStore.inputValue);
15- let selectedIndex = $derived(omniboxStore.selectedIndex);
16- let suggestions = $derived(omniboxStore.suggestions);
17- let favorites = $derived(omniboxStore.favorites);
18- let matches = $derived(omniboxStore.matches);
19- let searchShortcuts = $derived(omniboxStore.searchShortcuts);
20- let inlineCompletion = $derived(omniboxStore.inlineCompletion);
21- let inlineSuggestion = $derived(omniboxStore.inlineSuggestion);
22-
23- // Cursor tracking for ghost text visibility
24- let cursorAtEnd = $state(true);
25-
26- // Input element ref and responsive styles (props for parent to bind)
27- interface Props {
28- inputElement?: HTMLInputElement;
29- responsiveStyles?: {
30- fontSize: string;
31- inputPadding: string;
32- };
33- }
34-
35- let { inputElement = $bindable(), responsiveStyles }: Props = $props();
36-
37- const ACCENT_FALLBACK = 'var(--dynamic-accent)';
38-
39- interface CommandBadge {
40- prefix: string;
41- label: string;
42- }
43-
44- // Build command badges dynamically from search shortcuts
45- let COMMAND_BADGES = $derived(
46- Object.entries(searchShortcuts).map(([key, shortcut]) => ({
47- prefix: `${key}:`,
48- label: shortcut.description || key.toUpperCase()
49- }))
50- );
51-
52- const BADGE_LEFT_OFFSET = '64px';
53-
54- function resolveAccentColor(): string {
55- if (!inputElement) return ACCENT_FALLBACK;
56- const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
57- if (inlineAccent && inlineAccent.trim()) {
58- return inlineAccent.trim();
59- }
60- try {
61- const computedAccent = getComputedStyle(inputElement).getPropertyValue('--dynamic-accent');
62- if (computedAccent && computedAccent.trim()) {
63- return computedAccent.trim();
64- }
65- } catch { /* no-op */ }
66- return ACCENT_FALLBACK;
67- }
68-
69- // Computed placeholder
70- let placeholder = $derived(
71- mode === 'find' ? 'Find in page…' : 'Type URL or search…'
72- );
73-
74- const DEFAULT_PADDING: [string, string, string, string] = ['10px', '12px', '10px', '12px'];
75-
76- function normalizePadding(padding?: string): [string, string, string, string] {
77- if (!padding) return DEFAULT_PADDING;
78- const parts = padding.trim().split(/\s+/).filter(Boolean);
79- if (parts.length === 0) return DEFAULT_PADDING;
80- const p0 = parts[0] ?? DEFAULT_PADDING[0];
81- const p1 = parts[1] ?? p0;
82- const p2 = parts[2] ?? p0;
83- const p3 = parts[3] ?? p1;
84-
85- if (parts.length === 1) return [p0, p0, p0, p0];
86- if (parts.length === 2) return [p0, p1, p0, p1];
87- if (parts.length === 3) return [p0, p1, p2, p1];
88- return [p0, p1, p2, p3];
89- }
90-
91- let activeBadge = $state<CommandBadge | null>(null);
92- // Ghost text visibility - only show when cursor is at end and no command badge active
93- let showGhostText = $derived(
94- mode === 'omnibox' &&
95- inlineCompletion &&
96- cursorAtEnd &&
97- inputValue.length > 0 &&
98- !activeBadge
99- );
100- let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
101- let inputPadding = $state(DEFAULT_PADDING.join(' '));
102- let baseLeftPadding = $state(DEFAULT_PADDING[3]);
103- let inputFontSize = $derived(responsiveStyles?.fontSize || '16px');
104-
105- $effect(() => {
106- basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
107- });
108-
109- // Debug: log when inline suggestion state changes
110- $effect(() => {
111- console.log('[INLINE] State changed:', {
112- inlineCompletion,
113- inlineSuggestion,
114- showGhostText,
115- cursorAtEnd,
116- mode,
117- inputValueLength: inputValue?.length,
118- activeBadge: !!activeBadge
119- });
120- });
121-
122- $effect(() => {
123- const value = inputValue || '';
124- const trimmedValue = value.trimStart();
125- activeBadge = COMMAND_BADGES.find(({ prefix }) => trimmedValue.startsWith(prefix)) || null;
126- });
127-
128- $effect(() => {
129- const [top, right, bottom, left] = basePaddingSegments;
130- baseLeftPadding = left;
131- const leftPadding = activeBadge ? `calc(${left} + ${BADGE_LEFT_OFFSET})` : left;
132- inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;
133- });
134-
135- // Track cursor position for ghost text visibility
136- function updateCursorPosition() {
137- if (inputElement) {
138- cursorAtEnd = inputElement.selectionStart === inputValue.length &&
139- inputElement.selectionEnd === inputValue.length;
140- }
141- }
142-
143- // Accept inline suggestion (full or word-by-word)
144- function acceptInlineSuggestion(acceptMode: 'full' | 'word') {
145- if (!inlineSuggestion || !inlineCompletion) return;
146-
147- switch (acceptMode) {
148- case 'full':
149- // Accept entire suggestion
150- omniboxStore.setInputValue(inlineSuggestion);
151- omniboxStore.clearInlineSuggestion();
152- // Query for next prefix match
153- debouncedPrefixQuery(inlineSuggestion);
154- // Also update the search results
155- debouncedQuery(inlineSuggestion);
156- break;
157-
158- case 'word':
159- // Accept next word at cursor position
160- const cursorPos = inputElement?.selectionStart ?? inputValue.length;
161- const remainingCompletion = inlineSuggestion.slice(cursorPos);
162-
163- // Find next word boundary (handles /, ., -, _ as boundaries)
164- const wordMatch = remainingCompletion.match(/^([^\s\/\.\-\_]+[\s\/\.\-\_]?)/);
165- if (wordMatch && wordMatch[1]) {
166- const wordToInsert = wordMatch[1];
167- const newValue = inputValue.slice(0, cursorPos) + wordToInsert + inputValue.slice(cursorPos);
168- const newCursorPos = cursorPos + wordToInsert.length;
169-
170- omniboxStore.setInputValue(newValue);
171-
172- requestAnimationFrame(() => {
173- if (inputElement) {
174- inputElement.setSelectionRange(newCursorPos, newCursorPos);
175- updateCursorPosition();
176- }
177- });
178-
179- debouncedPrefixQuery(newValue);
180- debouncedQuery(newValue);
181- }
182- break;
183- }
184- }
185-
186- // Handle input changes
187- function handleInput(event: Event) {
188- const target = event.target as HTMLInputElement;
189- const value = target.value || '';
190-
191- omniboxStore.setInputValue(value);
192- omniboxStore.setFaded(false);
193- updateCursorPosition();
194-
195- if (mode === 'omnibox') {
196- // Only query backend if we're in history view
197- // In favorites view, filtering happens locally via computed state
198- if (viewMode === 'history') {
199- if (value === '') {
200- // Input is empty - fetch initial history based on config
201- omniboxBridge.fetchInitialHistory();
202- omniboxStore.clearInlineSuggestion();
203- } else {
204- // Input has content - perform search
205- debouncedQuery(value);
206- // Also query for inline suggestion (fish-style ghost text)
207- debouncedPrefixQuery(value);
208- }
209- }
210- } else if (mode === 'find') {
211- // Debounced find with minimum length to avoid freeze on first letter
212- debouncedFind(value, findInPage);
213- }
214- }
215-
216- // Handle key events
217- function handleKeyDown(event: KeyboardEvent) {
218- switch (event.key) {
219- case 'Escape':
220- event.preventDefault();
221- event.stopPropagation();
222-
223- if (inputValue && inputValue.trim() !== '') {
224- // Input has text - clear it but keep omnibox open
225- omniboxStore.setInputValue('');
226-
227- // In find mode, clear the search highlights
228- if (mode === 'find') {
229- findInPage('');
230- }
231- } else {
232- // Input is empty - close omnibox immediately
233- omniboxStore.close();
234- }
235- break;
236-
237- case 'Enter':
238- event.preventDefault();
239- event.stopPropagation();
240- handleEnterKey(event);
241- break;
242-
243- case 'Tab':
244- // Only handle Tab in omnibox mode (not in find mode)
245- if (mode === 'omnibox') {
246- event.preventDefault();
247- event.stopPropagation();
248- // Toggle between history and favorites views
249- const newViewMode = viewMode === 'history' ? 'favorites' : 'history';
250- omniboxStore.setViewMode(newViewMode);
251- }
252- break;
253-
254- case ' ':
255- // Only handle Space in omnibox mode when an item is selected
256- if (mode === 'omnibox' && selectedIndex >= 0) {
257- event.preventDefault();
258- event.stopPropagation();
259- handleSpaceKey();
260- }
261- break;
262-
263- case 'ArrowDown':
264- case 'ArrowUp':
265- event.preventDefault();
266- event.stopPropagation();
267- handleArrowKeys(event.key);
268- break;
269-
270- case 'ArrowRight':
271- // Handle inline suggestion acceptance (fish-style)
272- if (mode === 'omnibox' && inlineCompletion) {
273- if (event.ctrlKey || event.metaKey) {
274- // Ctrl+Right: accept next word
275- event.preventDefault();
276- event.stopPropagation();
277- acceptInlineSuggestion('word');
278- } else if (cursorAtEnd) {
279- // Right at end: accept full suggestion
280- event.preventDefault();
281- event.stopPropagation();
282- acceptInlineSuggestion('full');
283- }
284- // If cursor not at end and no modifier, let default behavior happen
285- }
286- break;
287-
288- case 'y':
289- // Ctrl+Y: accept full inline suggestion (alternative to Right Arrow)
290- if (event.ctrlKey && mode === 'omnibox' && inlineCompletion) {
291- event.preventDefault();
292- event.stopPropagation();
293- acceptInlineSuggestion('full');
294- }
295- break;
296-
297- default:
298- // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results
299- // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.)
300- if (event.ctrlKey && mode === 'omnibox') {
301- const code = event.code;
302- let targetIndex = -1;
303-
304- // Map physical keys: Digit1->0, Digit2->1, ..., Digit9->8, Digit0->9
305- if (code >= 'Digit1' && code <= 'Digit9') {
306- targetIndex = parseInt(code.substring(5), 10) - 1;
307- } else if (code === 'Digit0') {
308- targetIndex = 9;
309- }
310-
311- if (targetIndex >= 0) {
312- event.preventDefault();
313- event.stopPropagation();
314- handleNumberKey(targetIndex);
315- return;
316- }
317- }
318-
319- // For normal typing keys, only stop propagation to prevent page handlers
320- // but don't prevent default so typing still works in the input
321- event.stopPropagation();
322- // Any other key should restore full opacity
323- omniboxStore.setFaded(false);
324- break;
325- }
326- }
327-
328- // Handle Enter key based on mode
329- function handleEnterKey(event: KeyboardEvent) {
330- if (mode === 'omnibox') {
331- // Get selected item from current view (history or favorites)
332- const currentList = viewMode === 'history' ? suggestions : favorites;
333- const selectedItem = selectedIndex >= 0 ? currentList[selectedIndex] : null;
334- const url = selectedItem?.url || inputValue || '';
335-
336- if (url) {
337- omniboxBridge.navigate(url);
338- omniboxStore.close();
339- }
340- } else if (mode === 'find') {
341- if (event.shiftKey) {
342- // Shift+Enter: jump to previous match
343- jumpToMatch(-1);
344- } else if (event.altKey) {
345- // Alt+Enter: center on current match but keep overlay open
346- revealCurrentMatch();
347- } else {
348- // Enter: center and close
349- revealCurrentMatch();
350- omniboxStore.close();
351- }
352- }
353- }
354-
355- // Handle Space key to toggle favorite
356- function handleSpaceKey() {
357- if (mode !== 'omnibox' || selectedIndex < 0) return;
358-
359- // Get the selected item based on current view mode
360- const item = viewMode === 'history'
361- ? suggestions[selectedIndex]
362- : favorites[selectedIndex];
363-
364- if (!item || !item.url) return;
365-
366- // Extract title and favicon with proper type narrowing
367- const title = 'title' in item ? item.title : '';
368- const faviconURL = 'favicon_url' in item ? item.favicon_url : ('favicon' in item ? item.favicon : '') ?? '';
369-
370- // Toggle the favorite status
371- omniboxBridge.toggleFavorite(item.url, title, faviconURL);
372-
373- // Refresh the favorites list after toggling
374- setTimeout(() => {
375- omniboxBridge.fetchFavorites();
376- }, 100);
377- }
378-
379- // Handle Ctrl+Number key for quick navigation
380- function handleNumberKey(targetIndex: number) {
381- if (mode !== 'omnibox') return;
382-
383- // Get the current list based on view mode
384- const currentList = viewMode === 'history' ? suggestions : favorites;
385-
386- // Check if the target index is valid
387- if (targetIndex >= currentList.length) return;
388-
389- const item = currentList[targetIndex];
390- if (!item || !item.url) return;
391-
392- // Navigate to the item and close omnibox
393- omniboxBridge.navigate(item.url);
394- omniboxStore.close();
395- }
396-
397- // Handle arrow key navigation
398- function handleArrowKeys(key: string) {
399- let totalItems: number;
400- if (mode === 'omnibox') {
401- // Get count from current view (history or favorites)
402- totalItems = viewMode === 'history' ? suggestions.length : favorites.length;
403- } else {
404- totalItems = matches.length;
405- }
406-
407- if (totalItems > 0) {
408- if (key === 'ArrowDown') {
409- omniboxStore.selectNext();
410- } else {
411- omniboxStore.selectPrevious();
412- }
413-
414- // Fade overlay while navigating results only for find mode
415- if (mode === 'find') {
416- omniboxStore.setFaded(true);
417- } else {
418- omniboxStore.setFaded(false);
419- }
420-
421- // For find mode, reveal the current match
422- if (mode === 'find') {
423- revealCurrentMatch();
424- }
425- }
426- }
427-
428- // Jump to match (for find mode)
429- function jumpToMatch(delta: number) {
430- if (mode !== 'find' || matches.length === 0) return;
431-
432- const currentIndex = selectedIndex || 0;
433- const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
434-
435- omniboxStore.setSelectedIndex(newIndex);
436- revealCurrentMatch();
437- }
438-
439- // Reveal current match (for find mode)
440- function revealCurrentMatch() {
441- if (mode !== 'find' || selectedIndex < 0 || !matches[selectedIndex]) return;
442-
443- // Remove previous active class
444- const prevMatch = matches[omniboxStore.activeIndex];
445- if (prevMatch?.element?.classList) {
446- prevMatch.element.classList.remove('find-active');
447- }
448-
449- // Add active class to current match
450- const currentMatch = matches[selectedIndex];
451- if (currentMatch?.element?.classList) {
452- currentMatch.element.classList.add('find-active');
453- omniboxStore.setActiveIndex(selectedIndex);
454-
455- // Scroll into view
456- try {
457- currentMatch.element.scrollIntoView({
458- block: 'center',
459- inline: 'nearest'
460- });
461- } catch {
462- currentMatch.element.scrollIntoView();
463- }
464- }
465- }
466-
467- // Handle mouse events
468- function handleMouseDown() {
469- omniboxStore.setFaded(false);
470- }
471-
472- function handleFocus() {
473- omniboxStore.setFaded(false);
474- if (inputElement) {
475- const accent = resolveAccentColor();
476- inputElement.style.setProperty('--dumber-input-border-color', accent);
477- }
478- }
479-
480- function handleBlur() {
481- if (!inputElement) return;
482- const base = inputElement.style.getPropertyValue('--dumber-input-border-base');
483- const fallback = (base && base.trim()) || 'var(--dynamic-border)';
484- inputElement.style.setProperty('--dumber-input-border-color', fallback);
485- }
486-
487- function handleMouseEnter() {
488- if (omniboxStore.visible && inputElement) {
489- try {
490- inputElement.focus({ preventScroll: true });
491- inputElement.select();
492- } catch {
493- inputElement.focus();
494- }
495- }
496- }
497-
498- // Handle selection changes (for cursor tracking)
499- function handleSelect() {
500- updateCursorPosition();
501- }
502-
503- // Handle click with cursor tracking
504- function handleClickWithCursor() {
505- omniboxStore.setFaded(false);
506- updateCursorPosition();
507- }
508-</script>
509-
510-<div class="omnibox-input-wrapper">
511- {#if activeBadge}
512- <span
513- class="omnibox-prefix-badge"
514- aria-hidden="true"
515- style={`left: ${baseLeftPadding};`}
516- >{activeBadge.label}</span>
517- {/if}
518-
519- <!-- Ghost text overlay (fish-style inline suggestion) -->
520- {#if showGhostText}
521- <div
522- class="omnibox-ghost-text"
523- aria-hidden="true"
524- style={`padding: ${inputPadding}; font-size: ${inputFontSize};`}
525- >
526- <span class="ghost-spacer">{inputValue}</span><span class="ghost-completion">{inlineCompletion}</span>
527- </div>
528- {/if}
529-
530- <input
531- bind:this={inputElement}
532- type="text"
533- {placeholder}
534- value={inputValue}
535- class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
536- class:has-ghost-text={showGhostText}
537- style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
538- oninput={handleInput}
539- onkeydown={handleKeyDown}
540- onmousedown={handleMouseDown}
541- onclick={handleClickWithCursor}
542- onselect={handleSelect}
543- onfocus={handleFocus}
544- onblur={handleBlur}
545- onmouseenter={handleMouseEnter}
546- autocomplete="off"
547- spellcheck="false"
548- role="combobox"
549- aria-expanded={omniboxStore.hasContent}
550- aria-controls="omnibox-list"
551- aria-haspopup="listbox"
552- aria-owns="omnibox-list"
553- aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
554- />
555-</div>
556-
557-<style>
558- .omnibox-input-wrapper {
559- position: relative;
560- }
561-
562- .omnibox-input-field {
563- background: var(--dumber-input-bg, var(--dynamic-bg));
564- color: var(--dynamic-text);
565- border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
566- border-radius: 2px;
567- box-shadow:
568- inset 0 1px 2px rgba(0, 0, 0, 0.15),
569- inset 0 0 0 1px rgba(0, 0, 0, 0.03);
570- transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
571- font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
572- letter-spacing: normal;
573- }
574-
575- .omnibox-input-field::placeholder {
576- color: var(--dynamic-muted);
577- letter-spacing: normal;
578- }
579-
580- .omnibox-input-field:focus {
581- color: var(--dynamic-text);
582- border-color: var(--dynamic-accent);
583- box-shadow:
584- inset 0 1px 2px rgba(0, 0, 0, 0.15),
585- 0 0 0 1px var(--dynamic-accent);
586- }
587-
588- .omnibox-prefix-badge {
589- position: absolute;
590- top: 50%;
591- transform: translateY(-50%);
592- padding: 0.2rem 0.6rem;
593- border-radius: 0;
594- border: 1px solid var(--dynamic-border, transparent);
595- background: var(--dumber-input-badge-bg, var(--dynamic-accent));
596- color: var(--dumber-input-badge-text, var(--dynamic-bg));
597- font-size: 0.75rem;
598- font-weight: 600;
599- display: inline-flex;
600- align-items: center;
601- gap: 4px;
602- line-height: 1;
603- pointer-events: none;
604- white-space: nowrap;
605- box-shadow: 0 0 0 1px var(--dynamic-border, transparent);
606- z-index: 1;
607- }
608-
609- /* Ghost text overlay for fish-style inline suggestions */
610- .omnibox-ghost-text {
611- position: absolute;
612- top: 0;
613- left: 0;
614- right: 0;
615- bottom: 0;
616- pointer-events: none;
617- display: flex;
618- align-items: center;
619- white-space: nowrap;
620- overflow: hidden;
621- font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
622- letter-spacing: normal;
623- z-index: 0;
624- box-sizing: border-box;
625- border: 1px solid transparent;
626- }
627-
628- .ghost-spacer {
629- visibility: hidden;
630- white-space: pre;
631- }
632-
633- .ghost-completion {
634- color: var(--dynamic-muted);
635- opacity: 0.5;
636- white-space: pre;
637- transition: opacity 100ms ease-out;
638- }
639-
640- .omnibox-ghost-text {
641- transition: opacity 80ms ease-out;
642- }
643-
644- /* Make input transparent when showing ghost text */
645- .omnibox-input-field.has-ghost-text {
646- background: transparent !important;
647- }
648-
649- /* Ensure wrapper provides background when ghost text is active */
650- .omnibox-input-wrapper:has(.has-ghost-text) {
651- background: var(--dumber-input-bg, var(--dynamic-bg));
652- border-radius: 2px;
653- }
654-</style>
D webui/src/components/omnibox/OmniboxSuggestions.svelte +0 -396
@@ -1,396 +0,0 @@
1-<!--
2- Omnibox Suggestions Component
3-
4- Displays search suggestions with favicons and URL highlighting
5--->
6-<script lang="ts">
7- import { omniboxStore } from './stores.svelte.ts';
8- import { omniboxBridge } from './messaging';
9-
10- // Reactive state
11- let suggestions = $derived(omniboxStore.suggestions);
12- let favorites = $derived(omniboxStore.favorites);
13- let viewMode = $derived(omniboxStore.viewMode);
14- let selectedIndex = $derived(omniboxStore.selectedIndex);
15- let inputValue = $derived(omniboxStore.inputValue);
16- let hasContent = $state(false);
17-
18- // Debug effect to log favorites changes
19- $effect(() => {
20- console.log('[OmniboxSuggestions] Favorites updated, count:', favorites.length);
21- });
22-
23- // Computed: Get current list based on view mode with local filtering for favorites
24- let currentList = $derived.by(() => {
25- const list = viewMode === 'history' ? suggestions : favorites;
26-
27- // In favorites view with input, filter locally
28- if (viewMode === 'favorites' && inputValue && inputValue.trim()) {
29- const query = inputValue.toLowerCase().trim();
30- return list.filter(item =>
31- item.url.toLowerCase().includes(query) ||
32- ('title' in item && item.title && item.title.toLowerCase().includes(query))
33- );
34- }
35-
36- return list;
37- });
38-
39- // Update hasContent - always show in omnibox mode to display header
40- $effect(() => {
41- hasContent = omniboxStore.mode === 'omnibox';
42- });
43-
44- // Check if a URL is in favorites
45- function isFavorited(url: string): boolean {
46- return favorites.some(fav => fav.url === url);
47- }
48-
49- // Handle suggestion item mouse enter
50- function handleItemMouseEnter(index: number) {
51- omniboxStore.setSelectedIndex(index);
52- omniboxStore.setFaded(false);
53- scrollToSelection();
54- }
55-
56- // Handle suggestion item click
57- function handleItemClick(suggestion: any) {
58- omniboxBridge.navigate(suggestion.url);
59- omniboxStore.close();
60- }
61-
62- // Handle keyboard activation on suggestion item
63- function handleItemKeyDown(event: KeyboardEvent, suggestion: any) {
64- if (event.key === 'Enter' || event.key === ' ') {
65- event.preventDefault();
66- handleItemClick(suggestion);
67- }
68- }
69-
70- // Scroll list to show selected item
71- function scrollToSelection() {
72- const selectedItem = document.getElementById(`omnibox-item-${selectedIndex}`);
73- if (selectedItem && selectedItem.scrollIntoView) {
74- try {
75- selectedItem.scrollIntoView({ block: 'nearest' });
76- } catch {
77- selectedItem.scrollIntoView();
78- }
79- }
80- }
81-
82- // Parse URL for display
83- function parseUrl(url: string) {
84- try {
85- const urlObj = new URL(url, window.location.href);
86- return {
87- domain: urlObj.hostname,
88- path: (urlObj.pathname || '') + (urlObj.search || '') + (urlObj.hash || '')
89- };
90- } catch {
91- return {
92- domain: url || '',
93- path: ''
94- };
95- }
96- }
97-
98- // Handle favicon error
99- function handleFaviconError(event: Event) {
100- const target = event.target as HTMLImageElement;
101- const chip = target.parentElement;
102- if (chip) {
103- chip.style.display = 'none';
104- }
105- }
106-
107- // Watch for selection changes to scroll
108- $effect(() => {
109- if (selectedIndex >= 0) {
110- scrollToSelection();
111- }
112- });
113-</script>
114-
115-{#if hasContent}
116- <div
117- id="omnibox-list"
118- class="suggestion-list"
119- role="listbox"
120- aria-label="Search suggestions"
121- >
122- <!-- View mode indicator -->
123- <div class="view-mode-header">
124- <span class={viewMode === 'history' ? 'view-tab active' : 'view-tab'}>
125- History ({suggestions.length})
126- </span>
127- <span class={viewMode === 'favorites' ? 'view-tab active' : 'view-tab'}>
128- Favorites ({favorites.length})
129- </span>
130- <span class="view-hint">Tab to switch</span>
131- </div>
132-
133- {#if currentList.length === 0}
134- <!-- Empty state -->
135- {#if viewMode === 'favorites'}
136- <!-- Always show empty state for favorites -->
137- <div class="empty-state">
138- <span class="empty-icon">⭐</span>
139- <span class="empty-text">No favorites yet</span>
140- <span class="empty-hint">Press Space on any item in History to add it here</span>
141- </div>
142- {:else if inputValue && inputValue.trim()}
143- <!-- Only show "No results found" for history when user has typed something -->
144- <div class="empty-state">
145- <span class="empty-icon">🔍</span>
146- <span class="empty-text">No results found</span>
147- </div>
148- {/if}
149- {:else}
150- {#each currentList as item, index (item.url)}
151- {@const { domain, path } = parseUrl(item.url)}
152- {@const isSelected = index === selectedIndex}
153- {@const favicon = ('favicon' in item ? item.favicon : '') || ('favicon_url' in item ? item.favicon_url : '') || ''}
154- {@const isFav = viewMode === 'history' && isFavorited(item.url)}
155- {@const shortcutNumber = index < 9 ? index + 1 : index === 9 ? 0 : null}
156-
157- <div
158- id="omnibox-item-{index}"
159- class:suggestion-item={true}
160- class:selected={isSelected}
161- class:favorited={isFav}
162- role="option"
163- tabindex={isSelected ? 0 : -1}
164- aria-selected={isSelected}
165- onmouseenter={() => handleItemMouseEnter(index)}
166- onclick={() => handleItemClick(item)}
167- onkeydown={(e) => handleItemKeyDown(e, item)}
168- >
169- <!-- Favicon chip -->
170- {#if favicon}
171- <div class="suggestion-favicon">
172- <img
173- src={favicon}
174- alt=""
175- class="suggestion-favicon-img"
176- loading="lazy"
177- onerror={handleFaviconError}
178- />
179- </div>
180- {/if}
181-
182- <!-- URL text with gradient fade -->
183- <div class="suggestion-text">
184- <!-- Domain -->
185- <span class="suggestion-domain">
186- {domain}
187- </span>
188-
189- <!-- Separator -->
190- <span class="suggestion-separator">
191- {' | '}
192- </span>
193-
194- <!-- Path -->
195- <span class="suggestion-path">
196- {path || '/'}
197- </span>
198- </div>
199-
200- <!-- Keyboard shortcut hint (Ctrl+1-9, Ctrl+0 for 10th) -->
201- {#if shortcutNumber !== null}
202- <div class="suggestion-shortcut-hint" aria-hidden="true">
203- {shortcutNumber}
204- </div>
205- {/if}
206- </div>
207- {/each}
208- {/if}
209- </div>
210-{/if}
211-
212-
213-
214-<style>
215- .suggestion-list {
216- margin-top: 0.5rem;
217- max-height: 50vh;
218- overflow-y: auto;
219- border-top: 1px solid var(--dynamic-border);
220- background: var(--dynamic-bg);
221- border-radius: 0 0 2px 2px;
222- }
223-
224- .view-mode-header {
225- display: flex;
226- gap: 0.5rem;
227- align-items: center;
228- padding: 0.5rem 0.85rem;
229- border-bottom: 1px solid var(--dynamic-border);
230- background: color-mix(in srgb, var(--dynamic-bg) 95%, var(--dynamic-text));
231- font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
232- }
233-
234- .view-tab {
235- padding: 0.25rem 0.75rem;
236- border-radius: 2px;
237- font-size: 0.85rem;
238- font-weight: 500;
239- color: var(--dynamic-muted);
240- transition: all 100ms ease;
241- }
242-
243- .view-tab.active {
244- color: var(--dynamic-accent);
245- background: color-mix(in srgb, var(--dynamic-accent) 15%, transparent);
246- }
247-
248- .view-hint {
249- margin-left: auto;
250- font-size: 0.75rem;
251- color: var(--dynamic-muted);
252- opacity: 0.7;
253- }
254-
255- .empty-state {
256- display: flex;
257- flex-direction: column;
258- align-items: center;
259- justify-content: center;
260- padding: 2rem 1rem;
261- gap: 0.5rem;
262- color: var(--dynamic-muted);
263- text-align: center;
264- }
265-
266- .empty-icon {
267- font-size: 2rem;
268- opacity: 0.5;
269- }
270-
271- .empty-text {
272- font-size: 0.95rem;
273- font-weight: 500;
274- color: var(--dynamic-text);
275- opacity: 0.7;
276- }
277-
278- .empty-hint {
279- font-size: 0.8rem;
280- opacity: 0.6;
281- max-width: 300px;
282- }
283-
284- .suggestion-item {
285- display: flex;
286- gap: 0.5rem;
287- align-items: center;
288- padding: 0.5rem 0.75rem;
289- border-bottom: 1px solid color-mix(in srgb, var(--dynamic-border) 50%, transparent);
290- cursor: pointer;
291- transition: background-color 100ms ease, border-left-color 100ms ease;
292- letter-spacing: normal;
293- border-left: 3px solid transparent;
294- position: relative;
295- font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
296- }
297-
298- .suggestion-item:last-child {
299- border-bottom: none;
300- }
301-
302- /* Favorited items get a permanent yellow left border */
303- .suggestion-item.favorited {
304- border-left-color: #f59e0b;
305- }
306-
307- .suggestion-item.selected,
308- .suggestion-item:hover,
309- .suggestion-item:focus-visible {
310- background: color-mix(in srgb, var(--dynamic-accent) 15%, var(--dynamic-bg));
311- border-left-color: var(--dynamic-accent);
312- color: var(--dynamic-text);
313- outline: none;
314- }
315-
316- /* Keep yellow border even when selected/hover for favorited items */
317- .suggestion-item.favorited.selected,
318- .suggestion-item.favorited:hover,
319- .suggestion-item.favorited:focus-visible {
320- border-left-color: #f59e0b;
321- }
322-
323- .suggestion-item.selected {
324- font-weight: 500;
325- }
326-
327- .suggestion-item .suggestion-domain {
328- color: var(--dynamic-text);
329- opacity: 0.95;
330- }
331-
332- .suggestion-item .suggestion-separator {
333- color: var(--dynamic-muted);
334- }
335-
336- .suggestion-item .suggestion-path {
337- color: var(--dynamic-muted);
338- }
339-
340- .suggestion-favicon {
341- width: 16px;
342- height: 16px;
343- border: none;
344- border-radius: 2px;
345- display: flex;
346- align-items: center;
347- justify-content: center;
348- background: transparent;
349- flex-shrink: 0;
350- overflow: hidden;
351- }
352-
353- .suggestion-favicon-img {
354- width: 16px;
355- height: 16px;
356- object-fit: contain;
357- image-rendering: auto;
358- }
359-
360- .suggestion-text {
361- flex: 1;
362- min-width: 0;
363- display: flex;
364- gap: 0.75rem;
365- align-items: center;
366- white-space: nowrap;
367- overflow: hidden;
368- text-overflow: ellipsis;
369- mask-image: linear-gradient(90deg, black 85%, transparent 100%);
370- -webkit-mask-image: linear-gradient(90deg, black 85%, transparent 100%);
371- }
372-
373- .suggestion-shortcut-hint {
374- position: absolute;
375- right: 0.85rem;
376- top: 50%;
377- transform: translateY(-50%);
378- font-size: 0.75rem;
379- font-weight: 600;
380- color: var(--dynamic-muted);
381- opacity: 0.6;
382- background: var(--dynamic-bg);
383- padding: 0.25rem 0.5rem;
384- border-radius: 3px;
385- border: 1px solid var(--dynamic-border);
386- z-index: 10;
387- pointer-events: none;
388- transition: opacity 100ms ease, color 100ms ease;
389- }
390-
391- .suggestion-item.selected .suggestion-shortcut-hint,
392- .suggestion-item:hover .suggestion-shortcut-hint {
393- opacity: 0.9;
394- color: var(--dynamic-accent);
395- }
396-</style>
D webui/src/components/omnibox/find.ts +0 -248
@@ -1,248 +0,0 @@
1-/**
2- * Find in Page Functionality
3- *
4- * Text search and highlighting implementation
5- */
6-
7-import type { FindMatch, HighlightNode } from "./types";
8-import { omniboxStore } from "./stores.svelte.ts";
9-
10-// Track whether we've injected highlight styles into the main document.
11-let findStylesInjected = false;
12-
13-function ensureFindStyles(): void {
14- if (findStylesInjected) {
15- return;
16- }
17-
18- try {
19- const style = document.createElement("style");
20- style.id = "dumber-find-styles";
21- style.textContent = `
22- .find-highlight {
23- background-color: rgb(255 235 59);
24- color: rgb(0 0 0);
25- padding: 0 0.125rem;
26- border-radius: 0;
27- box-shadow: 0 0 0 1px rgb(200 185 0) inset;
28- }
29-
30- .find-active {
31- background-color: rgb(255 152 0) !important;
32- color: rgb(0 0 0);
33- box-shadow: 0 0 0 1px rgb(179 107 0) inset !important;
34- }
35- `;
36-
37- (document.head || document.documentElement).appendChild(style);
38- findStylesInjected = true;
39- } catch (error) {
40- console.warn("Failed to inject find styles:", error);
41- }
42-}
43-
44-/**
45- * Find text in the current page and highlight matches
46- */
47-export function findInPage(query: string): void {
48- // Clear previous highlights
49- omniboxStore.clearHighlights();
50-
51- const trimmedQuery = (query || "").trim();
52- if (!trimmedQuery) {
53- return;
54- }
55-
56- const body = document.body;
57- if (!body) {
58- return;
59- }
60-
61- ensureFindStyles();
62-
63- // Get omnibox root to exclude from search
64- const omniboxRoot = document.getElementById("dumber-omnibox-root");
65- const matches: FindMatch[] = [];
66- const maxMatches = omniboxStore.config.maxMatches;
67-
68- // Create tree walker to find text nodes
69- const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
70- acceptNode(node: Text): number {
71- // Skip empty or whitespace-only nodes
72- if (!node.nodeValue || !node.nodeValue.trim()) {
73- return NodeFilter.FILTER_REJECT;
74- }
75-
76- const parentElement = node.parentElement;
77- if (!parentElement) {
78- return NodeFilter.FILTER_REJECT;
79- }
80-
81- // Skip omnibox content
82- if (omniboxRoot && omniboxRoot.contains(parentElement)) {
83- return NodeFilter.FILTER_REJECT;
84- }
85-
86- // Skip script, style, and noscript tags
87- const tagName = parentElement.tagName;
88- if (
89- tagName === "SCRIPT" ||
90- tagName === "STYLE" ||
91- tagName === "NOSCRIPT"
92- ) {
93- return NodeFilter.FILTER_REJECT;
94- }
95-
96- // Skip hidden elements
97- const computedStyle = getComputedStyle(parentElement);
98- if (
99- computedStyle.visibility === "hidden" ||
100- computedStyle.display === "none"
101- ) {
102- return NodeFilter.FILTER_REJECT;
103- }
104-
105- return NodeFilter.FILTER_ACCEPT;
106- },
107- });
108-
109- const queryLowerCase = trimmedQuery.toLowerCase();
110-
111- // Process each text node
112- while (walker.nextNode() && matches.length < maxMatches) {
113- const textNode = walker.currentNode as Text;
114- let nodeValue = textNode.nodeValue || "";
115- let searchIndex = 0;
116-
117- // Find all matches in this text node
118- while (nodeValue && matches.length < maxMatches) {
119- const matchIndex = nodeValue
120- .toLowerCase()
121- .indexOf(queryLowerCase, searchIndex);
122- if (matchIndex === -1) {
123- break;
124- }
125-
126- // Create text nodes for before, match, and after
127- const beforeText = nodeValue.slice(0, matchIndex);
128- const matchText = nodeValue.slice(
129- matchIndex,
130- matchIndex + trimmedQuery.length,
131- );
132- const afterText = nodeValue.slice(matchIndex + trimmedQuery.length);
133-
134- // Create elements
135- const beforeNode = document.createTextNode(beforeText);
136- const matchTextNode = document.createTextNode(matchText);
137- const highlightSpan = document.createElement("span");
138-
139- // Style the highlight span
140- highlightSpan.className = "find-highlight";
141- highlightSpan.appendChild(matchTextNode);
142-
143- // Insert into DOM
144- const parent = textNode.parentNode;
145- if (!parent) {
146- break;
147- }
148-
149- parent.insertBefore(beforeNode, textNode);
150- parent.insertBefore(highlightSpan, textNode);
151- textNode.nodeValue = afterText;
152-
153- // Track highlight node for cleanup
154- const highlightNode: HighlightNode = {
155- span: highlightSpan,
156- text: matchTextNode,
157- };
158- omniboxStore.addHighlightNode(highlightNode);
159-
160- // Create match context
161- const leftContext = beforeText.slice(-30);
162- let rightContext = afterText.slice(0, 30);
163-
164- // Try to break at word boundaries
165- const boundaryMatch = rightContext.match(/[.,;:-]/);
166- if (boundaryMatch && boundaryMatch.index !== undefined) {
167- rightContext = rightContext.slice(0, boundaryMatch.index + 1);
168- }
169-
170- const context = (leftContext + matchText + rightContext)
171- .replace(/\s+/g, " ")
172- .trim();
173-
174- // Add to matches
175- const findMatch: FindMatch = {
176- element: highlightSpan,
177- context,
178- };
179- matches.push(findMatch);
180-
181- // Continue searching in the remaining text
182- nodeValue = afterText;
183- searchIndex = 0;
184- }
185- }
186-
187- // Update store with matches
188- omniboxStore.updateMatches(matches);
189-
190- // Reveal first match if any
191- if (matches.length > 0) {
192- revealMatch(0);
193- }
194-}
195-
196-/**
197- * Reveal a specific match by index
198- */
199-export function revealMatch(index: number): void {
200- const matches = omniboxStore.matches;
201- if (index < 0 || index >= matches.length) {
202- return;
203- }
204-
205- // Remove previous active class
206- const prevActiveIndex = omniboxStore.activeIndex;
207- if (prevActiveIndex >= 0 && matches[prevActiveIndex]) {
208- const prevElement = matches[prevActiveIndex].element;
209- if (prevElement.classList) {
210- prevElement.classList.remove("find-active");
211- }
212- }
213-
214- // Add active class to current match
215- const currentMatch = matches[index];
216- if (currentMatch && currentMatch.element.classList) {
217- currentMatch.element.classList.add("find-active");
218- omniboxStore.setActiveIndex(index);
219-
220- // Scroll into view
221- try {
222- currentMatch.element.scrollIntoView({
223- block: "center",
224- inline: "nearest",
225- });
226- } catch {
227- currentMatch.element.scrollIntoView();
228- }
229- }
230-}
231-
232-/**
233- * Jump to next/previous match
234- */
235-export function jumpToMatch(delta: number): void {
236- const matches = omniboxStore.matches;
237- if (matches.length === 0) {
238- return;
239- }
240-
241- const currentIndex = omniboxStore.selectedIndex || 0;
242- const newIndex =
243- (((currentIndex + delta) % matches.length) + matches.length) %
244- matches.length;
245-
246- omniboxStore.setSelectedIndex(newIndex);
247- revealMatch(newIndex);
248-}
D webui/src/components/omnibox/index.ts +0 -16
@@ -1,16 +0,0 @@
1-/**
2- * Omnibox Component Exports
3- *
4- * Main entry point for omnibox functionality
5- */
6-
7-export { default as Omnibox } from "./Omnibox.svelte";
8-export { default as OmniboxInput } from "./OmniboxInput.svelte";
9-export { default as OmniboxSuggestions } from "./OmniboxSuggestions.svelte";
10-export { default as OmniboxFind } from "./OmniboxFind.svelte";
11-
12-export { omniboxStore } from "./stores.svelte.ts";
13-export { omniboxBridge } from "./messaging";
14-export { findInPage, revealMatch, jumpToMatch } from "./find";
15-
16-export type * from "./types";
D webui/src/components/omnibox/messaging.ts +0 -323
@@ -1,323 +0,0 @@
1-/**
2- * Omnibox Message Bridge
3- *
4- * TypeScript interface for Go-JavaScript communication
5- */
6-
7-import type { OmniboxMessage, OmniboxMessageBridge, Suggestion, Favorite, SearchShortcut } from "./types";
8-import { omniboxStore } from "./stores.svelte.ts";
9-
10-export class OmniboxBridge implements OmniboxMessageBridge {
11- // Forward messages via CustomEvent to main world bridge
12- // The main world will forward to webkit.messageHandlers.dumber
13- postMessage(msg: OmniboxMessage): void {
14- console.log("📤 [DEBUG] Posting message to backend via bridge:", msg);
15- try {
16- // Structure message for Go backend: type + payload + webview_id
17- // Go expects: { type: string, payload: {...}, webview_id: number }
18- const { type, ...rest } = msg;
19- const structuredMessage = {
20- type,
21- payload: rest,
22- webview_id: (window as any).__dumber_webview_id ?? 0,
23- };
24- // Dispatch CustomEvent that main-world bridge will listen to
25- document.dispatchEvent(new CustomEvent("dumber:isolated-message", {
26- detail: { payload: structuredMessage }
27- }));
28- console.log("✅ [DEBUG] Dispatched isolated message event");
29- } catch (e) {
30- console.error("❌ [DEBUG] Failed to dispatch message:", e);
31- // Fallback navigation if dispatch fails
32- if (msg.type === "navigate" && typeof msg.url === "string" && msg.url) {
33- try {
34- window.location.href = msg.url;
35- } catch (navError) {
36- console.error("Fallback navigation failed:", navError);
37- }
38- }
39- }
40- }
41-
42- /**
43- * Update suggestions from Go backend
44- */
45- setSuggestions(suggestions: Suggestion[]): void {
46- console.log("📝 [DEBUG] Received suggestions from backend:", suggestions);
47- omniboxStore.updateSuggestions(suggestions);
48- }
49-
50- /**
51- * Update search shortcuts from Go backend
52- */
53- setSearchShortcuts(shortcuts: Record<string, SearchShortcut>): void {
54- console.log("📝 [DEBUG] Received search shortcuts from backend:", shortcuts);
55- omniboxStore.updateSearchShortcuts(shortcuts);
56- }
57-
58- /**
59- * Handle navigation request
60- */
61- navigate(url: string): void {
62- console.log("🚀 Omnibox navigate called with:", url);
63- this.postMessage({ type: "navigate", url });
64- }
65-
66- /**
67- * Handle search query
68- */
69- query(searchTerm: string, limit?: number): void {
70- const q = searchTerm ?? "";
71- const lim = limit || omniboxStore.config.defaultLimit;
72- console.log("🔍 [DEBUG] Sending query to backend:", { q, limit: lim });
73- // Send to native handler; Go will compute suggestions and call setSuggestions
74- this.postMessage({ type: "query", q, limit: lim });
75- }
76-
77- /**
78- * Fetch initial history for empty omnibox
79- */
80- fetchInitialHistory(limit?: number): void {
81- const lim = limit || omniboxStore.config.defaultLimit;
82- console.log("[DEBUG] Fetching initial history:", { limit: lim });
83- // Send to native handler; Go will compute suggestions based on config and call setSuggestions
84- this.postMessage({ type: "omnibox_initial_history", limit: lim });
85- }
86-
87- /**
88- * Fetch search shortcuts from backend via messaging bridge
89- */
90- async fetchSearchShortcuts(): Promise<void> {
91- return new Promise((resolve, reject) => {
92- // Set up one-time response handler via CustomEvent (isolated world pattern)
93- const handleShortcuts = ((event: CustomEvent) => {
94- try {
95- const data = event.detail?.shortcuts ?? event.detail;
96- // Type guard
97- if (typeof data !== "object" || data === null) {
98- reject(new Error("Invalid shortcuts data"));
99- return;
100- }
101- const dataObj = data as Record<string, unknown>;
102- // Normalize to match our SearchShortcut type
103- const normalized: Record<string, SearchShortcut> = {};
104- for (const [key, value] of Object.entries(dataObj)) {
105- const v = value as Record<string, unknown>;
106- normalized[key] = {
107- url: (v.url ?? v.URL ?? "") as string,
108- description: (v.description ?? v.Description ?? "") as string,
109- };
110- }
111- this.setSearchShortcuts(normalized);
112- document.removeEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
113- resolve();
114- } catch (error) {
115- console.error("Failed to process search shortcuts:", error);
116- document.removeEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
117- reject(error);
118- }
119- }) as EventListener;
120-
121- // Listen for response CustomEvent from main world bridge
122- document.addEventListener("dumber:search-shortcuts", handleShortcuts as EventListener);
123-
124- // Send message via CustomEvent bridge (isolated world cannot access webkit.messageHandlers)
125- this.postMessage({ type: "get_search_shortcuts" });
126- });
127- }
128-
129- /**
130- * Update favorites from Go backend
131- */
132- setFavorites(favorites: Favorite[]): void {
133- console.log("📝 [DEBUG] Received favorites from backend:", favorites);
134- omniboxStore.updateFavorites(favorites);
135- }
136-
137- /**
138- * Fetch favorites from backend via messaging bridge
139- */
140- async fetchFavorites(): Promise<void> {
141- return new Promise((resolve, reject) => {
142- // Set up one-time event listener for favorites response
143- const handleFavorites = ((event: CustomEvent) => {
144- try {
145- const { favorites } = event.detail;
146- if (!Array.isArray(favorites)) {
147- reject(new Error("Invalid favorites data"));
148- return;
149- }
150- console.log("📝 [DEBUG] Received favorites via CustomEvent:", favorites);
151- this.setFavorites(favorites as Favorite[]);
152-
153- // Clean up event listener
154- document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
155-
156- resolve();
157- } catch (error) {
158- console.error("Failed to process favorites:", error);
159- document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
160- reject(error);
161- }
162- }) as EventListener;
163-
164- // Listen for favorites event from main world bridge
165- document.addEventListener("dumber:favorites", handleFavorites as EventListener);
166-
167- // Send message to Go backend via CustomEvent bridge (like other omnibox messages)
168- this.postMessage({ type: "get_favorites" });
169-
170- // Timeout after 5 seconds
171- setTimeout(() => {
172- document.removeEventListener("dumber:favorites", handleFavorites as EventListener);
173- reject(new Error("Favorites fetch timeout"));
174- }, 5000);
175- });
176- }
177-
178- /**
179- * Toggle favorite status for a URL
180- */
181- toggleFavorite(url: string, title: string, faviconURL: string): void {
182- console.log("⭐ [DEBUG] Toggling favorite:", { url, title, faviconURL });
183- this.postMessage({ type: "toggle_favorite", url, title, faviconURL });
184-
185- // Notify other parts of the app that favorites changed
186- // Homepage will listen for this and refresh its favorites list
187- setTimeout(() => {
188- document.dispatchEvent(new CustomEvent("dumber:favorites-changed"));
189- }, 100); // Small delay to let backend complete
190- }
191-
192- /**
193- * Handle inline suggestion from backend (fish-style ghost text)
194- */
195- setInlineSuggestion(url: string | null): void {
196- console.log('[INLINE] bridge.setInlineSuggestion called:', url);
197- const inputValue = omniboxStore.inputValue;
198- omniboxStore.setInlineSuggestion(url, inputValue);
199- }
200-
201- /**
202- * Query for prefix-matching URL (for inline suggestions)
203- */
204- prefixQuery(prefix: string): void {
205- console.log('[INLINE] prefixQuery called:', prefix);
206- if (!prefix || prefix.trim().length < MIN_SEARCH_LENGTH) {
207- omniboxStore.clearInlineSuggestion();
208- return;
209- }
210- this.postMessage({ type: "prefix_query", q: prefix });
211- console.log('[INLINE] prefix_query message sent');
212- }
213-
214- // Suggestions are returned via setSuggestions() when native handler responds
215-}
216-
217-// Singleton instance
218-export const omniboxBridge = new OmniboxBridge();
219-
220-// Minimum characters required before triggering search operations
221-const MIN_SEARCH_LENGTH = 2;
222-
223-// Debounce timers for different operations
224-const debounceTimers: Record<string, number> = {};
225-
226-/**
227- * Creates a debounced search function with minimum length threshold
228- * Prevents freezing on first letter and rapid input
229- */
230-function createDebouncedSearch(
231- key: string,
232- action: (query: string) => void,
233- onClear?: () => void
234-): (query: string) => void {
235- return (query: string) => {
236- // Clear previous timer
237- if (debounceTimers[key]) {
238- clearTimeout(debounceTimers[key]);
239- debounceTimers[key] = 0;
240- }
241-
242- const trimmed = (query || "").trim();
243-
244- // Clear/reset immediately if query is empty or too short
245- if (trimmed.length < MIN_SEARCH_LENGTH) {
246- if (onClear) {
247- onClear();
248- }
249- return;
250- }
251-
252- // Debounce the actual search operation
253- debounceTimers[key] = window.setTimeout(() => {
254- action(query);
255- }, omniboxStore.config.debounceDelay);
256- };
257-}
258-
259-/**
260- * Debounced query function for omnibox search input
261- */
262-export const debouncedQuery = createDebouncedSearch(
263- "query",
264- (searchTerm) => omniboxBridge.query(searchTerm),
265- () => omniboxStore.updateSuggestions([])
266-);
267-
268-/**
269- * Debounced find function for find-in-page
270- */
271-export function debouncedFind(query: string, findFn: (q: string) => void): void {
272- createDebouncedSearch("find", findFn, () => findFn(""))(query);
273-}
274-
275-// Debounce for inline suggestions - balanced between responsiveness and smoothness
276-const PREFIX_DEBOUNCE_MS = 80;
277-let prefixDebounceTimer = 0;
278-
279-/**
280- * Debounced prefix query for inline suggestions (fish-style ghost text)
281- */
282-export function debouncedPrefixQuery(query: string): void {
283- if (prefixDebounceTimer) {
284- clearTimeout(prefixDebounceTimer);
285- prefixDebounceTimer = 0;
286- }
287-
288- const trimmed = (query || "").trim();
289- if (trimmed.length < MIN_SEARCH_LENGTH) {
290- // Clear after a short delay to avoid flicker
291- prefixDebounceTimer = window.setTimeout(() => {
292- omniboxStore.clearInlineSuggestion();
293- }, 50);
294- return;
295- }
296-
297- prefixDebounceTimer = window.setTimeout(() => {
298- omniboxBridge.prefixQuery(trimmed);
299- }, PREFIX_DEBOUNCE_MS);
300-}
301-
302-// Extend global window interface for Go bridge compatibility
303-declare global {
304- interface Window {
305- webkit?: {
306- messageHandlers?: {
307- dumber?: {
308- postMessage: (message: unknown) => void;
309- };
310- };
311- };
312- // Omnibox API for Go bridge
313- __dumber_omnibox?: {
314- setSuggestions: (suggestions: Suggestion[]) => void;
315- setInlineSuggestion: (url: string | null) => void;
316- toggle: () => void;
317- open: (mode?: string, query?: string) => void;
318- close: () => void;
319- findQuery: (query: string) => void;
320- setActive: (active: boolean) => void;
321- };
322- }
323-}
D webui/src/components/omnibox/stores.svelte.ts +0 -355
@@ -1,355 +0,0 @@
1-/**
2- * Omnibox State Management
3- *
4- * Svelte 5 rune-based state management for omnibox functionality
5- */
6-
7-import type {
8- OmniboxMode,
9- ViewMode,
10- Suggestion,
11- Favorite,
12- FindMatch,
13- HighlightNode,
14- OmniboxConfig,
15- SearchShortcut,
16-} from "./types";
17-
18-// Default configuration
19-const DEFAULT_CONFIG: Required<OmniboxConfig> = {
20- maxMatches: 2000,
21- debounceDelay: 120,
22- defaultLimit: 10,
23-};
24-
25-// Load viewMode from localStorage or default to "history"
26-function loadViewMode(): ViewMode {
27- try {
28- const stored = localStorage.getItem("dumber_omnibox_viewMode");
29- if (stored === "favorites" || stored === "history") {
30- return stored;
31- }
32- } catch (e) {
33- console.warn("Failed to load viewMode from localStorage:", e);
34- }
35- return "history";
36-}
37-
38-// Global state using Svelte 5 runes
39-let visible = $state(false);
40-let mode = $state<OmniboxMode>("omnibox");
41-let viewMode = $state<ViewMode>(loadViewMode());
42-let suggestions = $state<Suggestion[]>([]);
43-let favorites = $state<Favorite[]>([]);
44-let matches = $state<FindMatch[]>([]);
45-let selectedIndex = $state(-1);
46-let activeIndex = $state(-1);
47-let faded = $state(false);
48-let inputValue = $state("");
49-let highlightNodes = $state<HighlightNode[]>([]);
50-let prevOverflow = $state("");
51-let debounceTimer = $state(0);
52-let searchShortcuts = $state<Record<string, SearchShortcut>>({});
53-
54-// Inline suggestion state (fish-style ghost text)
55-let inlineSuggestion = $state<string | null>(null);
56-let inlineCompletion = $state<string | null>(null);
57-
58-// Configuration
59-let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
60-
61-export const omniboxStore = {
62- // State getters
63- get visible() {
64- return visible;
65- },
66- get mode() {
67- return mode;
68- },
69- get viewMode() {
70- return viewMode;
71- },
72- get suggestions() {
73- return suggestions;
74- },
75- get favorites() {
76- return favorites;
77- },
78- get matches() {
79- return matches;
80- },
81- get selectedIndex() {
82- return selectedIndex;
83- },
84- get activeIndex() {
85- return activeIndex;
86- },
87- get faded() {
88- return faded;
89- },
90- get inputValue() {
91- return inputValue;
92- },
93- get highlightNodes() {
94- return highlightNodes;
95- },
96- get prevOverflow() {
97- return prevOverflow;
98- },
99- get config() {
100- return config;
101- },
102- get searchShortcuts() {
103- return searchShortcuts;
104- },
105- get inlineSuggestion() {
106- return inlineSuggestion;
107- },
108- get inlineCompletion() {
109- return inlineCompletion;
110- },
111-
112- // Computed getters
113- get hasContent() {
114- if (mode === "find") {
115- return matches.length > 0;
116- }
117- // In omnibox mode, check the current view
118- return viewMode === "history" ? suggestions.length > 0 : favorites.length > 0;
119- },
120- get totalItems() {
121- if (mode === "find") {
122- return matches.length;
123- }
124- // In omnibox mode, return count based on current view
125- return viewMode === "history" ? suggestions.length : favorites.length;
126- },
127- get selectedItem() {
128- if (selectedIndex < 0) return null;
129- if (mode === "find") {
130- return matches[selectedIndex];
131- }
132- // In omnibox mode, return item from current view
133- return viewMode === "history"
134- ? suggestions[selectedIndex]
135- : favorites[selectedIndex];
136- },
137-
138- // Actions
139- setVisible(value: boolean) {
140- if (value && !visible) {
141- // Store scroll state when opening
142- prevOverflow = document.documentElement.style.overflow;
143- document.documentElement.style.overflow = "hidden";
144- } else if (!value && visible) {
145- // Restore scroll state when closing
146- document.documentElement.style.overflow = prevOverflow || "";
147- prevOverflow = "";
148-
149- // Clear find highlights when closing
150- if (mode === "find") {
151- this.clearHighlights();
152- }
153- }
154- visible = value;
155- },
156-
157- setMode(newMode: OmniboxMode) {
158- // Clear previous mode's state
159- if (newMode === "omnibox") {
160- // Switching to omnibox mode - clear find state
161- this.clearHighlights();
162- } else if (newMode === "find") {
163- // Switching to find mode - clear suggestions
164- this.updateSuggestions([]);
165- }
166-
167- mode = newMode;
168- selectedIndex = -1;
169- faded = false;
170- inputValue = ""; // Clear input for fresh start
171- },
172-
173- setInputValue(value: string) {
174- inputValue = value;
175- },
176-
177- setFaded(value: boolean) {
178- faded = value;
179- },
180-
181- setSelectedIndex(index: number) {
182- const maxIndex = this.totalItems - 1;
183- if (maxIndex < 0) {
184- selectedIndex = -1;
185- } else {
186- selectedIndex = Math.max(0, Math.min(index, maxIndex));
187- }
188- },
189-
190- selectNext() {
191- if (this.totalItems === 0) return;
192- const newIndex =
193- selectedIndex < 0 ? 0 : (selectedIndex + 1) % this.totalItems;
194- this.setSelectedIndex(newIndex);
195- faded = true;
196- },
197-
198- selectPrevious() {
199- if (this.totalItems === 0) return;
200- const newIndex =
201- selectedIndex <= 0 ? this.totalItems - 1 : selectedIndex - 1;
202- this.setSelectedIndex(newIndex);
203- faded = true;
204- },
205-
206- open(newMode: OmniboxMode = "omnibox", initialQuery?: string) {
207- this.setMode(newMode);
208- this.setVisible(true);
209- if (typeof initialQuery === "string") {
210- inputValue = initialQuery;
211- }
212- },
213-
214- close() {
215- this.setVisible(false);
216- faded = false;
217- },
218-
219- toggle() {
220- if (!visible) {
221- // When opening via toggle (Ctrl+L), always open in omnibox mode
222- this.setMode("omnibox");
223- this.setVisible(true);
224- } else {
225- this.setVisible(false);
226- }
227- },
228-
229- updateSuggestions(newSuggestions: Suggestion[]) {
230- suggestions = Array.isArray(newSuggestions) ? newSuggestions : [];
231- selectedIndex = -1;
232- },
233-
234- updateMatches(newMatches: FindMatch[]) {
235- matches = Array.isArray(newMatches) ? newMatches : [];
236- selectedIndex = newMatches.length > 0 ? 0 : -1;
237- },
238-
239- clearHighlights() {
240- try {
241- highlightNodes.forEach(({ span, text }) => {
242- const parent = span.parentNode;
243- if (parent) {
244- parent.replaceChild(text, span);
245- parent.normalize();
246- }
247- });
248- } catch (error) {
249- console.warn("Error clearing highlights:", error);
250- }
251- highlightNodes = [];
252- matches = [];
253- selectedIndex = -1;
254- activeIndex = -1;
255- },
256-
257- addHighlightNode(node: HighlightNode) {
258- highlightNodes = [...highlightNodes, node];
259- },
260-
261- setActiveIndex(index: number) {
262- activeIndex = index;
263- },
264-
265- setDebounceTimer(timerId: number) {
266- if (debounceTimer) {
267- clearTimeout(debounceTimer);
268- }
269- debounceTimer = timerId;
270- },
271-
272- clearDebounceTimer() {
273- if (debounceTimer) {
274- clearTimeout(debounceTimer);
275- debounceTimer = 0;
276- }
277- },
278-
279- updateConfig(newConfig: Partial<OmniboxConfig>) {
280- config = { ...config, ...newConfig };
281- },
282-
283- updateSearchShortcuts(shortcuts: Record<string, SearchShortcut>) {
284- searchShortcuts = shortcuts;
285- },
286-
287- setViewMode(newViewMode: ViewMode) {
288- viewMode = newViewMode;
289- selectedIndex = -1; // Reset selection when switching views
290-
291- // Persist to localStorage
292- try {
293- localStorage.setItem("dumber_omnibox_viewMode", newViewMode);
294- } catch (e) {
295- console.warn("Failed to save viewMode to localStorage:", e);
296- }
297- },
298-
299- updateFavorites(newFavorites: Favorite[]) {
300- favorites = Array.isArray(newFavorites) ? newFavorites : [];
301- // Reset selection if we're in favorites view
302- if (viewMode === "favorites") {
303- selectedIndex = -1;
304- }
305- },
306-
307- setInlineSuggestion(url: string | null, currentInput: string) {
308- console.log('[INLINE] setInlineSuggestion called:', { url, currentInput });
309- inlineSuggestion = url;
310- if (!url || !currentInput) {
311- inlineCompletion = null;
312- console.log('[INLINE] Cleared - missing url or input');
313- return;
314- }
315-
316- // Normalize URL by stripping protocol and www for matching
317- let normalizedUrl = url.toLowerCase();
318- normalizedUrl = normalizedUrl.replace(/^https?:\/\//, '');
319- normalizedUrl = normalizedUrl.replace(/^www\./, '');
320-
321- const normalizedInput = currentInput.toLowerCase();
322-
323- if (normalizedUrl.startsWith(normalizedInput)) {
324- // Show the completion (rest of normalized URL after what user typed)
325- inlineCompletion = normalizedUrl.slice(normalizedInput.length);
326- console.log('[INLINE] Set completion:', inlineCompletion);
327- } else {
328- inlineCompletion = null;
329- console.log('[INLINE] Cleared - no prefix match after normalization');
330- }
331- },
332-
333- clearInlineSuggestion() {
334- inlineSuggestion = null;
335- inlineCompletion = null;
336- },
337-
338- reset() {
339- visible = false;
340- mode = "omnibox";
341- // Keep viewMode persistent - it's user preference
342- suggestions = [];
343- favorites = [];
344- matches = [];
345- selectedIndex = -1;
346- activeIndex = -1;
347- faded = false;
348- inputValue = "";
349- prevOverflow = "";
350- inlineSuggestion = null;
351- inlineCompletion = null;
352- this.clearHighlights();
353- this.clearDebounceTimer();
354- },
355-};
D webui/src/components/omnibox/types.ts +0 -83
@@ -1,83 +0,0 @@
1-/**
2- * Omnibox Component Types
3- *
4- * TypeScript interfaces for omnibox/find functionality that match Go structs
5- */
6-
7-export interface Suggestion {
8- url: string;
9- favicon?: string;
10-}
11-
12-export interface Favorite {
13- id: number;
14- url: string;
15- title: string;
16- favicon_url: string;
17- position: number;
18-}
19-
20-export interface SearchShortcut {
21- url: string;
22- description: string;
23-}
24-
25-export interface OmniboxMessage {
26- type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite" | "prefix_query" | "keyboard_blocking";
27- url?: string;
28- q?: string;
29- limit?: number;
30- title?: string;
31- faviconURL?: string;
32- action?: "enable" | "disable";
33-}
34-
35-export interface FindMatch {
36- element: HTMLElement;
37- context: string;
38-}
39-
40-export interface HighlightNode {
41- span: HTMLElement;
42- text: Text;
43-}
44-
45-export type OmniboxMode = "omnibox" | "find";
46-export type ViewMode = "history" | "favorites";
47-
48-export interface OmniboxConfig {
49- maxMatches?: number;
50- debounceDelay?: number;
51- defaultLimit?: number;
52-}
53-
54-export interface OmniboxState {
55- visible: boolean;
56- mode: OmniboxMode;
57- viewMode: ViewMode;
58- suggestions: Suggestion[];
59- favorites: Favorite[];
60- matches: FindMatch[];
61- selectedIndex: number;
62- activeIndex: number;
63- faded: boolean;
64- inputValue: string;
65- highlightNodes: HighlightNode[];
66- prevOverflow: string;
67- searchShortcuts: Record<string, SearchShortcut>;
68-}
69-
70-// Message bridge interface for Go communication
71-export interface OmniboxMessageBridge {
72- postMessage(msg: OmniboxMessage): void;
73- setSuggestions(suggestions: Suggestion[]): void;
74-}
75-
76-// Global API for Go bridge
77-export interface OmniboxAPI {
78- setSuggestions: (suggestions: Suggestion[]) => void;
79- toggle: () => void;
80- open: (mode?: OmniboxMode, query?: string) => void;
81- close: () => void;
82- findQuery: (query: string) => void;
83-}
D webui/src/components/toast/Toast.svelte +0 -147
@@ -1,147 +0,0 @@
1-<script lang="ts">
2- import { onMount, onDestroy } from 'svelte';
3-
4- interface ToastProps {
5- id: number;
6- message: string;
7- duration?: number;
8- type?: 'info' | 'success' | 'error';
9- onDismiss: (id: number) => void;
10- }
11-
12- let { id, message, duration = 2500, type = 'info', onDismiss }: ToastProps = $props();
13-
14- let isExiting = $state(false);
15- let isDarkMode = $state(false);
16- let themeObserver: MutationObserver | undefined;
17- let timeoutId: number | undefined;
18-
19- onMount(() => {
20- timeoutId = window.setTimeout(() => {
21- dismiss();
22- }, duration);
23-
24- syncTheme();
25-
26- if (typeof window !== 'undefined' && 'MutationObserver' in window) {
27- themeObserver = new MutationObserver(syncTheme);
28- themeObserver.observe(document.documentElement, {
29- attributes: true,
30- attributeFilter: ['class']
31- });
32- }
33- });
34-
35- onDestroy(() => {
36- if (timeoutId) {
37- window.clearTimeout(timeoutId);
38- timeoutId = undefined;
39- }
40-
41- if (themeObserver) {
42- themeObserver.disconnect();
43- themeObserver = undefined;
44- }
45- });
46-
47- function dismiss() {
48- if (timeoutId) {
49- window.clearTimeout(timeoutId);
50- timeoutId = undefined;
51- }
52- isExiting = true;
53- // Allow time for exit animation before removing from parent
54- setTimeout(() => {
55- onDismiss(id);
56- }, 250);
57- }
58-
59- function handleClick() {
60- dismiss();
61- }
62-
63- function syncTheme() {
64- if (typeof document === 'undefined') {
65- return;
66- }
67-
68- const prefersDark = document.documentElement.classList.contains('dark');
69-
70- if (prefersDark) {
71- isDarkMode = true;
72- return;
73- }
74-
75- try {
76- isDarkMode = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
77- } catch {
78- isDarkMode = false;
79- }
80- }
81-</script>
82-
83-<button
84- class={`toast ${isDarkMode ? 'toast-dark' : 'toast-light'} toast-${type} ${isExiting ? 'toast-exit' : 'toast-enter'}`}
85- onclick={handleClick}
86- data-toast-id={id}
87- type="button"
88- aria-label="Dismiss notification"
89- title="Click to dismiss"
90->
91- <div class="toast-message">
92- {message || 'Notification'}
93- </div>
94-</button>
95-
96-<style>
97- .toast {
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);
103- border-radius: 0;
104- box-shadow: 0 8px 14px rgba(0, 0, 0, 0.08);
105- cursor: pointer;
106- display: inline-flex;
107- margin: 0;
108- pointer-events: auto;
109- padding: 0;
110- transition: transform 0.2s ease, opacity 0.2s ease;
111- }
112-
113- .toast-enter {
114- opacity: 1;
115- transform: translateY(0);
116- }
117-
118- .toast-exit {
119- opacity: 0;
120- transform: translateY(-16px);
121- }
122-
123- .toast-message {
124- color: var(--toast-text);
125- font-size: 0.875rem;
126- font-weight: 600;
127- min-width: 9rem;
128- padding: 0.75rem 1rem;
129- text-align: center;
130- }
131-
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%);
135- }
136-
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%);
140- }
141-
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%);
146- }
147-</style>
D webui/src/components/toast/ToastContainer.svelte +0 -157
@@ -1,157 +0,0 @@
1-<script lang="ts">
2- import Toast from './Toast.svelte';
3-
4- interface ToastData {
5- id: number;
6- message: string;
7- duration?: number;
8- type?: 'info' | 'success' | 'error';
9- }
10-
11- // Svelte 5 rune for reactive state
12- let toasts = $state<ToastData[]>([]);
13- let counter = $state(0);
14- let zoomToastId = $state<number | null>(null);
15- let zoomDebounceTimer: number | undefined = undefined;
16-
17- function addToast(message: string, duration = 2500, type: 'info' | 'success' | 'error' = 'info'): number {
18- const id = ++counter;
19- const toast: ToastData = { id, message, duration, type };
20-
21- toasts.push(toast);
22- console.log('📋 Toast:', message);
23- return id;
24- }
25-
26- function dismissToast(id: number) {
27- console.log(`[ToastContainer] Dismissing toast with id: ${id}`);
28- const index = toasts.findIndex(t => t.id === id);
29- if (index === -1) {
30- console.log(`[ToastContainer] Toast with id ${id} not found in array`);
31- return;
32- }
33-
34- // Clear zoom toast tracking if this is the zoom toast
35- if (zoomToastId === id) {
36- console.log(`[ToastContainer] Clearing zoom toast tracking for id: ${id}`);
37- zoomToastId = null;
38- }
39-
40- console.log(`[ToastContainer] Removing toast at index ${index}, remaining: ${toasts.length - 1}`);
41- toasts.splice(index, 1);
42- }
43-
44- function clearAllToasts() {
45- toasts = [];
46- zoomToastId = null;
47- if (zoomDebounceTimer) {
48- window.clearTimeout(zoomDebounceTimer);
49- zoomDebounceTimer = undefined;
50- }
51- }
52- // Export for external use
53- window.__dumber_clearToasts = clearAllToasts;
54-
55- function showZoomToast(zoomLevel: number) {
56- console.log('[dumber] showZoomToast called with level:', zoomLevel);
57-
58- // Debug: Check if toast container exists in DOM
59- const container = document.querySelector('.toast-container');
60- const browserRoot = document.querySelector('.browser-component-root');
61- console.log('[dumber] DOM check - container exists:', !!container, 'browser-root exists:', !!browserRoot, 'toasts array length:', toasts.length);
62-
63- // Clear existing debounce timer
64- if (zoomDebounceTimer) {
65- window.clearTimeout(zoomDebounceTimer);
66- }
67-
68- // Remove existing zoom toasts immediately (no animation needed for replacement)
69- toasts = toasts.filter(toast => {
70- const isZoomToast = toast.message.includes('Zoom level:') || toast.id === zoomToastId;
71- return !isZoomToast;
72- });
73-
74- // Show new zoom toast
75- const percentage = Math.round(zoomLevel * 100);
76- const message = `Zoom level: ${percentage}%`;
77- const id = addToast(message, 1500, 'info');
78- zoomToastId = id;
79-
80- console.log('[dumber] Added zoom toast - id:', id, 'current toasts count:', toasts.length);
81-
82- // Debounced cleanup for rapid zoom changes
83- zoomDebounceTimer = window.setTimeout(() => {
84- zoomToastId = null;
85- zoomDebounceTimer = undefined;
86- }, 1500);
87- }
88-
89- // Listen for toast events from main world bridge
90- if (typeof document !== 'undefined') {
91- // Listen for general toast requests
92- document.addEventListener('dumber:toast', ((event: CustomEvent) => {
93- const { message, duration, type } = event.detail;
94- console.log('[ToastContainer] Received toast event:', { message, duration, type });
95- addToast(message, duration, type);
96- }) as EventListener);
97-
98- // Listen for zoom toast requests
99- document.addEventListener('dumber:toast:zoom', ((event: CustomEvent) => {
100- const { level } = event.detail;
101- console.log('[ToastContainer] Received zoom toast event:', level);
102- showZoomToast(level);
103- }) as EventListener);
104-
105- console.log('✅ Toast event listeners set up for CustomEvents');
106- }
107-
108- // Effect that runs when toasts array changes
109- $effect(() => {
110- const container = document.querySelector('.toast-container');
111- const browserRoot = document.querySelector('.browser-component-root');
112- console.log('[dumber] Toast state changed - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length, 'toasts:', toasts.map(t => t.message));
113- });
114-
115- // One-time setup effect
116- $effect(() => {
117- console.log('🔄 ToastContainer initialized, functions exposed');
118-
119- // Monitor container periodically for debugging (disabled)
120- // const monitorInterval = setInterval(() => {
121- // const container = document.querySelector('.toast-container');
122- // const browserRoot = document.querySelector('.browser-component-root');
123- // console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);
124- // }, 5000);
125-
126- return () => {
127- // clearInterval(monitorInterval);
128- console.log('🗑️ ToastContainer cleanup');
129- };
130- });
131-</script>
132-
133-<div class="browser-component-root toast-container">
134- {#each toasts as toast (toast.id)}
135- <Toast
136- id={toast.id}
137- message={toast.message}
138- duration={toast.duration}
139- type={toast.type}
140- onDismiss={dismissToast}
141- />
142- {/each}
143-</div>
144-
145-<style>
146- .toast-container {
147- display: flex;
148- flex-direction: column;
149- gap: 0.5rem;
150- left: 0.5rem;
151- max-width: 20rem;
152- pointer-events: none;
153- position: fixed;
154- top: 0.5rem;
155- z-index: 2147483647;
156- }
157-</style>
D webui/src/injected/bootstrap.ts +0 -422
@@ -1,422 +0,0 @@
1-/**
2- * Dumber GUI bootstrap routine shared between injected pages and special schemes.
3- *
4- * Sets up toast, omnibox, keyboard service, and workspace event listeners.
5- */
6-
7-import { initializeToast, type ToastConfig } from "./modules/toast";
8-import {
9- initializeOmnibox,
10- type OmniboxInitConfig,
11-} from "./modules/omnibox";
12-import "./modules/workspace"; // Auto-initializes pane mode UI notifications
13-import { keyboardService, type KeyboardService } from "$lib/keyboard";
14-import type { Suggestion } from "../components/omnibox/types";
15-
16-declare global {
17- interface Window {
18- __dumber_gui?: DumberGUI;
19- __dumber_gui_ready?: boolean;
20- __dumber_gui_ready_for?: Document | null;
21- __dumber_showToast?: (
22- message: string,
23- duration?: number,
24- type?: "info" | "success" | "error",
25- ) => number | void;
26- __dumber_showZoomToast?: (level: number) => void;
27- __dumber_keyboard?: KeyboardService;
28- __dumber_omnibox?: {
29- setSuggestions: (suggestions: Suggestion[]) => void;
30- setInlineSuggestion: (url: string | null) => void;
31- toggle: () => void;
32- open: (mode?: string, query?: string) => void;
33- close: () => void;
34- findQuery: (query: string) => void;
35- setActive: (active: boolean) => void;
36- };
37- __dumber_gui_bootstrap?: () => void;
38- __dumber_toggle?: () => void;
39- __dumber_find_open?: (query?: string) => void;
40- __dumber_find_close?: () => void;
41- __dumber_find_query?: (query: string) => void;
42- __dumber_dismissToast?: (id: number) => void;
43- __dumber_clearToasts?: () => void;
44- webkit?: {
45- messageHandlers?: {
46- dumber?: {
47- postMessage: (message: unknown) => void;
48- };
49- };
50- };
51- __dumber_webview_id?: number | string;
52- }
53-}
54-
55-interface DumberGUI {
56- initializeToast: (config?: ToastConfig) => Promise<void>;
57- initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>;
58- keyboard: KeyboardService;
59- isReady: boolean;
60-}
61-
62-function whenDOMReady(callback: () => void) {
63- if (document.readyState === "loading") {
64- document.addEventListener("DOMContentLoaded", callback, { once: true });
65- } else if (document.readyState === "interactive" && !document.body) {
66- setTimeout(() => whenDOMReady(callback), 10);
67- } else {
68- callback();
69- }
70-}
71-
72-function _whenPageLoaded(callback: () => void) {
73- if (document.readyState === "complete") {
74- callback();
75- } else {
76- window.addEventListener("load", callback, { once: true });
77- }
78-}
79-void _whenPageLoaded; // Reserved for future use
80-
81-function normalizeWebviewId(value: unknown): number | null {
82- if (typeof value === "number" && Number.isFinite(value)) {
83- return value;
84- }
85- if (typeof value === "string") {
86- const trimmed = value.trim();
87- if (!trimmed || trimmed === "__WEBVIEW_ID__") {
88- return null;
89- }
90- const parsed = Number.parseInt(trimmed, 10);
91- return Number.isNaN(parsed) ? null : parsed;
92- }
93-
94- return null;
95-}
96-
97-export function bootstrapGUI(): void {
98- if (window.__dumber_gui_ready_for === document) {
99- return;
100- }
101-
102- if (window.self !== window.top) {
103- console.log("🚫 Dumber GUI skipped in iframe");
104- return;
105- }
106-
107- window.__dumber_gui_ready_for = document;
108- window.__dumber_gui_ready = true;
109-
110- try {
111- window.addEventListener("pagehide", () => {
112- window.__dumber_gui_ready = false;
113- window.__dumber_gui_ready_for = null;
114- }, { once: true });
115- } catch {
116- // Ignore environments without pagehide support
117- }
118-
119- // Request color palettes from Go backend
120- // The isolated world has access to webkit.messageHandlers, main world does not.
121- // Main world has set up the __dumber_color_palettes callback handler.
122- const requestColorPalettes = () => {
123- try {
124- const bridge = window.webkit?.messageHandlers?.dumber;
125- if (bridge && typeof bridge.postMessage === "function") {
126- const webviewId = window.__dumber_webview_id;
127- console.log("[dumber-palette] Requesting color palettes from backend, webview_id:", webviewId);
128- bridge.postMessage({
129- type: "get_color_palettes",
130- webview_id: typeof webviewId === "number" ? webviewId : 0,
131- });
132- } else {
133- console.warn("[dumber-palette] WebKit message handler not available");
134- }
135- } catch (err) {
136- console.error("[dumber-palette] Failed to request color palettes:", err);
137- }
138- };
139- requestColorPalettes();
140-
141- let workspaceHasFocus = false;
142- const initialRawWebViewId = window.__dumber_webview_id;
143- let currentWebViewId = normalizeWebviewId(initialRawWebViewId);
144- if (currentWebViewId !== null) {
145- window.__dumber_webview_id = currentWebViewId;
146- }
147- let hasReceivedFocusEvent = false;
148-
149- if (currentWebViewId === null) {
150- console.log("[workspace] WebView ID unknown, requesting from Go backend");
151- if (window.webkit?.messageHandlers?.dumber) {
152- window.webkit.messageHandlers.dumber.postMessage({
153- type: "request-webview-id",
154- webview_id: 0,
155- payload: { timestamp: Date.now() },
156- });
157- }
158- }
159-
160- document.addEventListener("dumber:webview-id", (event: Event) => {
161- const detail = (event as CustomEvent).detail || {};
162- const incomingId = normalizeWebviewId(detail.webviewId);
163- if (incomingId !== null && incomingId !== currentWebViewId) {
164- currentWebViewId = incomingId;
165- window.__dumber_webview_id = incomingId;
166- console.log("[workspace] Received webview ID from Go:", incomingId);
167- }
168- });
169-
170- document.addEventListener("dumber:workspace-focus", (event: Event) => {
171- const detail = (event as CustomEvent).detail || {};
172- const eventWebviewId = normalizeWebviewId(detail?.webviewId);
173-
174- if (eventWebviewId !== null) {
175- if (currentWebViewId === null) {
176- currentWebViewId = eventWebviewId;
177- window.__dumber_webview_id = eventWebviewId;
178- console.log(
179- "[workspace] Learned webview ID from focus event:",
180- eventWebviewId,
181- );
182- } else if (eventWebviewId !== currentWebViewId) {
183- console.log("[workspace] Ignoring focus event for other webview", {
184- eventWebviewId,
185- self: currentWebViewId,
186- detail,
187- });
188- return;
189- }
190- }
191-
192- console.log("[workspace focus event]", detail, "prev focus=", workspaceHasFocus);
193-
194- hasReceivedFocusEvent = true;
195-
196- if (typeof detail?.active === "boolean") {
197- workspaceHasFocus = detail.active;
198- } else {
199- workspaceHasFocus = detail !== false;
200- }
201-
202- console.log(
203- "[workspace focus updated] focus=",
204- workspaceHasFocus,
205- "webviewId=",
206- currentWebViewId,
207- );
208-
209- if (window.__dumber_omnibox) {
210- if (workspaceHasFocus) {
211- window.__dumber_omnibox.setActive(true);
212- } else {
213- window.__dumber_omnibox.setActive(false);
214- window.__dumber_omnibox.close();
215- }
216- }
217- });
218-
219- // Initialize toast early (DOM ready is enough)
220- whenDOMReady(async () => {
221- console.log("[GUI] DOM ready, initializing toast");
222- try {
223- await initializeToast();
224- console.log("[GUI] Toast initialized successfully");
225- } catch (e) {
226- console.error("❌ Failed to initialize toast system:", e);
227- }
228- });
229-
230- // Initialize omnibox as soon as the DOM is ready (no need to wait for full load)
231- whenDOMReady(async () => {
232- console.log("[GUI] DOM ready, initializing omnibox");
233- try {
234- await initializeOmnibox();
235- console.log("✅ Omnibox system initialized successfully");
236- } catch (omniboxError) {
237- console.error("❌ Failed to initialize omnibox system:", omniboxError);
238- }
239- });
240-
241- window.__dumber_keyboard = keyboardService;
242-
243- whenDOMReady(() => {
244- document.addEventListener(
245- "keydown",
246- (event) => {
247- keyboardService.handleKeyboardEvent(event);
248- },
249- true,
250- );
251-
252- document.addEventListener(
253- "mousedown",
254- (event) => {
255- keyboardService.handleMouseEvent(event);
256- },
257- true,
258- );
259-
260- console.log("✅ KeyboardService initialized with global listeners");
261-
262- document.addEventListener("dumber:key", (e: Event) => {
263- const detail = (e as CustomEvent).detail || {};
264- if (detail && typeof detail.shortcut === "string") {
265- keyboardService.handleNativeShortcut(detail.shortcut);
266- }
267- });
268-
269- document.addEventListener("dumber:ui:shortcut", (e: Event) => {
270- const detail = (e as CustomEvent).detail || {};
271- const action = detail?.action;
272- const eventWebViewId = normalizeWebviewId(detail?.webviewId);
273- const source = detail?.source || "unknown";
274-
275- if (eventWebViewId !== null && currentWebViewId === null) {
276- currentWebViewId = eventWebViewId;
277- window.__dumber_webview_id = eventWebViewId;
278- console.log(
279- "[dumber shortcuts] Learned webview ID from shortcut event:",
280- eventWebViewId,
281- );
282- }
283-
284- console.log("[dumber shortcuts] Event received", {
285- action,
286- eventWebViewId,
287- currentWebViewId,
288- detail,
289- });
290-
291- if (typeof action !== "string") {
292- console.log("[dumber shortcuts] No action in event, ignoring");
293- return;
294- }
295-
296- const isForThisWebView =
297- eventWebViewId === null || eventWebViewId === currentWebViewId;
298- const isOmniboxAction = action.startsWith("omnibox-");
299- const shouldHandle =
300- isForThisWebView &&
301- (isOmniboxAction
302- ? hasReceivedFocusEvent
303- ? workspaceHasFocus
304- : true
305- : true);
306-
307- if (!shouldHandle) {
308- console.log("[dumber shortcuts] ignored action", {
309- action,
310- workspaceHasFocus,
311- isForThisWebView,
312- eventWebViewId,
313- currentWebViewId,
314- source,
315- detail,
316- hasReceivedFocusEvent,
317- });
318- return;
319- }
320-
321- console.log("[dumber shortcuts] handling action", {
322- action,
323- source,
324- webviewId: currentWebViewId,
325- });
326-
327- const omnibox = window.__dumber_omnibox;
328-
329- switch (action) {
330- case "omnibox-nav-toggle":
331- omnibox?.open?.("omnibox", detail?.query);
332- break;
333- case "omnibox-open":
334- omnibox?.open?.("omnibox", detail?.query);
335- break;
336- case "omnibox-find-toggle":
337- omnibox?.open?.("find", detail?.query);
338- break;
339- case "omnibox-close":
340- omnibox?.close?.();
341- break;
342- default:
343- console.warn("[dumber] Unknown UI shortcut action:", action);
344- }
345- });
346- });
347-
348- window.__dumber_toggle = () => {
349- try {
350- console.log("🎯 __dumber_toggle called");
351- console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox);
352- if (window.__dumber_omnibox?.toggle) {
353- console.log("✅ Using Svelte omnibox toggle");
354- window.__dumber_omnibox.toggle();
355- return;
356- }
357-
358- throw new Error("Omnibox toggle requested but API is unavailable");
359- } catch (error) {
360- console.error("❌ Error in __dumber_toggle:", error);
361- }
362- };
363-
364- window.__dumber_find_open = (query?: string) => {
365- try {
366- if (window.__dumber_omnibox?.open) {
367- window.__dumber_omnibox.open("find", query);
368- return;
369- }
370-
371- throw new Error("Omnibox find requested but API is unavailable");
372- } catch (error) {
373- console.error("❌ Error in __dumber_find_open:", error);
374- }
375- };
376-
377- window.__dumber_find_close = () => {
378- if (window.__dumber_omnibox?.close) {
379- window.__dumber_omnibox.close();
380- return;
381- }
382-
383- const error = new Error("Omnibox close requested but API is unavailable");
384- console.error("❌ Error in __dumber_find_close:", error);
385- };
386-
387- window.__dumber_find_query = (query: string) => {
388- if (window.__dumber_omnibox?.findQuery) {
389- window.__dumber_omnibox.findQuery(query);
390- }
391- };
392-
393- window.__dumber_gui = {
394- initializeToast: async (config?: ToastConfig) => {
395- return await initializeToast(config);
396- },
397- initializeOmnibox: async (config?: OmniboxInitConfig) => {
398- return await initializeOmnibox(config);
399- },
400- keyboard: keyboardService,
401- isReady: true,
402- };
403-
404- window.__dumber_gui_bootstrap = function () {
405- console.log("[gui-bootstrap] Initializing GUI for workspace");
406-
407- if (!window.__dumber_omnibox) {
408- console.log(
409- `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`,
410- );
411- } else {
412- console.log(
413- `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`,
414- );
415- window.__dumber_omnibox.setActive(workspaceHasFocus);
416- }
417-
418- console.log(
419- `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`,
420- );
421- };
422-}
D webui/src/injected/gui.ts +0 -14
@@ -1,14 +0,0 @@
1-/**
2- * Unified GUI bundle entry point.
3- *
4- * Delegates to the shared bootstrap so special pages can reuse the same setup
5- * without depending on this injected build.
6- *
7- * Always calls bootstrapGUI() - deduplication is handled inside bootstrap.ts
8- * via window.__dumber_gui_ready_for check. This ensures GUI loads in all
9- * contexts including new split panes and about:blank pages.
10- */
11-
12-import { bootstrapGUI } from "./bootstrap";
13-
14-bootstrapGUI();
D webui/src/injected/main-world.ts +0 -1105
@@ -1,1105 +0,0 @@
1-/**
2- * Main-world bridge script for Dumber Browser
3- *
4- * This script is injected into the page-world context to provide:
5- * - Window.open interceptor with debouncing
6- * - Toast notifications bridge
7- * - Theme management
8- * - DOM zoom functionality
9- * - Omnibox suggestions bridge
10- */
11-
12-// Type definitions for window extensions
13-declare global {
14- interface Window {
15- __dumber: DumberAPI;
16- __dumber_page_bridge_installed?: boolean;
17- __dumber_dom_zoom_seed?: number;
18- __dumber_dom_zoom_level?: number;
19- __dumber_initial_theme?: string;
20- __dumber_setTheme?: (theme: "light" | "dark") => void;
21- __dumber_applyPalette?: (theme: "light" | "dark") => void;
22- __dumber_palette?: Record<string, Record<string, string>>;
23- __dumber_color_palettes?: (palettes: unknown) => void;
24- __dumber_color_palettes_error?: (error: string) => void;
25- __dumber_search_shortcuts?: (data: unknown) => void;
26- __dumber_search_shortcuts_error?: (error: string) => void;
27- __dumber_applyDomZoom?: (level: number) => void;
28- __dumber_showToast?: (
29- message: string,
30- duration?: number,
31- type?: "info" | "success" | "error",
32- ) => number | void;
33- __dumber_showZoomToast?: (level: number) => void;
34- __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35- __dumber_omnibox_inline_suggestion?: (url: string | null) => void;
36- __dumber_favorites?: (favorites: unknown[]) => void;
37- __dumber_webview_id?: string | number;
38- __dumber_is_active?: boolean;
39- __dumber_teardown?: () => void;
40- __dumber_enableKeyboardBlocking?: () => void;
41- __dumber_disableKeyboardBlocking?: () => void;
42- __dumber_keyboard_blocking_active?: boolean;
43- __dumber_keyboard_blocker?: (e: Event) => void;
44- __dumber_focus_guard?: (e: Event) => void;
45- __dumber_click_blocker?: (e: Event) => void;
46- webkit?: {
47- messageHandlers?: {
48- dumber?: {
49- postMessage: (message: unknown) => void;
50- };
51- };
52- };
53- }
54-}
55-
56-interface Suggestion {
57- // Define proper suggestion types based on your needs
58- [key: string]: unknown;
59-}
60-
61-interface DumberAPI {
62- omnibox: {
63- suggestions: (suggestions: Suggestion[]) => void;
64- };
65-}
66-
67-(() => {
68- try {
69- // Prevent multiple installations
70- if (window.__dumber_page_bridge_installed) {
71- return;
72- }
73- window.__dumber_page_bridge_installed = true;
74-
75- const cleanupHandlers: Array<() => void> = [];
76- let teardownExecuted = false;
77-
78- // Initialize zoom level placeholder (will be replaced by Go)
79- const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go
80- window.__dumber_dom_zoom_seed = initialZoom;
81-
82- // Initialize WebView ID and active state (injected by Go at document-start)
83- // Only set fallback values if not already injected
84- if (typeof window.__dumber_webview_id === 'undefined') {
85- window.__dumber_webview_id = "__WEBVIEW_ID__";
86- }
87- if (typeof window.__dumber_is_active === 'undefined') {
88- window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
89- }
90-
91- // Set up color palette callback handlers (will be called from isolated world via CustomEvent)
92- window.__dumber_color_palettes = (palettes: unknown) => {
93- console.log("[dumber-palette] Received palettes from backend:", palettes);
94- try {
95- // Type guard to ensure palettes is a valid object
96- if (typeof palettes !== "object" || palettes === null) {
97- console.error("[dumber-palette] ERROR: Invalid palettes received - type:", typeof palettes);
98- return;
99- }
100-
101- window.__dumber_palette = palettes as Record<string, Record<string, string>>;
102- console.log("[dumber-palette] Palette keys:", Object.keys(window.__dumber_palette || {}));
103-
104- // Normalize palette keys to lowercase
105- const normalized: Record<string, Record<string, string>> = {};
106- if (window.__dumber_palette) {
107- for (const key in window.__dumber_palette) {
108- if (Object.prototype.hasOwnProperty.call(window.__dumber_palette, key)) {
109- const paletteValue = window.__dumber_palette[key];
110- if (paletteValue) {
111- normalized[key.toLowerCase()] = paletteValue;
112- console.log(`[dumber-palette] Normalized: ${key} -> ${key.toLowerCase()}, colors:`, Object.keys(paletteValue));
113- }
114- }
115- }
116- window.__dumber_palette = normalized;
117- }
118-
119- // Apply palette now that we have it
120- const initialTheme: "light" | "dark" = document.documentElement.classList.contains("dark") ? "dark" : "light";
121- console.log(`[dumber-palette] Initial theme detected: ${initialTheme}`);
122- window.__dumber_initial_theme = initialTheme;
123-
124- if (window.__dumber_applyPalette) {
125- console.log("[dumber-palette] Applying palette...");
126- window.__dumber_applyPalette(initialTheme);
127- } else {
128- console.error("[dumber-palette] ERROR: __dumber_applyPalette function not found");
129- }
130- } catch (err) {
131- console.error("[dumber-palette] ERROR: Failed to apply received palette:", err);
132- }
133- };
134-
135- window.__dumber_color_palettes_error = (error: string) => {
136- console.error("[dumber-palette] ERROR from backend:", error);
137- };
138-
139- // Request color palettes from Go backend via isolated world bridge
140- // Note: webkit.messageHandlers.dumber is only available in the isolated world,
141- // not in the main world. The request will be sent from the isolated world (gui.ts).
142- console.log("[dumber-palette] Color palette handlers registered, waiting for isolated world to request palettes");
143-
144- // Palette application function
145- window.__dumber_applyPalette = (theme: "light" | "dark") => {
146- console.log(`[dumber-palette] applyPalette called with theme: ${theme}`);
147- try {
148- const palette = window.__dumber_palette?.[theme] || window.__dumber_palette?.["light"];
149- if (!palette) {
150- console.error(`[dumber-palette] ERROR: No palette found for theme "${theme}". Available:`, Object.keys(window.__dumber_palette || {}));
151- return;
152- }
153- console.log(`[dumber-palette] Found palette for theme "${theme}":`, palette);
154-
155- const tokenMap: Record<string, string> = {
156- background: "--color-browser-bg",
157- surface: "--color-browser-surface",
158- surface_variant: "--color-browser-surface-variant",
159- text: "--color-browser-text",
160- muted: "--color-browser-muted",
161- accent: "--color-browser-accent",
162- border: "--color-browser-border",
163- };
164-
165- const dynamicMap: Record<string, string> = {
166- background: "--dynamic-bg",
167- surface: "--dynamic-surface",
168- surface_variant: "--dynamic-surface-variant",
169- text: "--dynamic-text",
170- muted: "--dynamic-muted",
171- accent: "--dynamic-accent",
172- border: "--dynamic-border",
173- };
174-
175- const root = document.documentElement;
176- let appliedCount = 0;
177- for (const key in tokenMap) {
178- if (Object.prototype.hasOwnProperty.call(tokenMap, key)) {
179- const colorValue = palette[key];
180- const tokenVar = tokenMap[key];
181- const dynamicVar = dynamicMap[key];
182- if (colorValue && typeof colorValue === "string" && tokenVar) {
183- root.style.setProperty(tokenVar, colorValue);
184- if (dynamicVar) {
185- root.style.setProperty(dynamicVar, colorValue);
186- }
187- console.log(`[dumber-palette] Applied ${key}: ${colorValue} -> ${tokenVar}`);
188- appliedCount++;
189- } else {
190- console.warn(`[dumber-palette] Skipped ${key}: colorValue=${colorValue}, tokenVar=${tokenVar}`);
191- }
192- }
193- }
194-
195- console.log(`[dumber-palette] Applied ${appliedCount} colors successfully`);
196- document.dispatchEvent(
197- new CustomEvent("dumber:palette-updated", { detail: { theme } })
198- );
199- } catch (err) {
200- console.error("[dumber-palette] EXCEPTION applying palette:", err);
201- }
202- };
203-
204- // Theme setter function for GTK theme integration
205- window.__dumber_setTheme = (theme: "light" | "dark") => {
206- window.__dumber_initial_theme = theme;
207- console.log("[dumber] Setting theme to:", theme);
208- if (theme === "dark") {
209- document.documentElement.classList.add("dark");
210- } else {
211- document.documentElement.classList.remove("dark");
212- }
213- try {
214- if (typeof window.__dumber_applyPalette === "function") {
215- window.__dumber_applyPalette(theme);
216- }
217- document.dispatchEvent(
218- new CustomEvent("dumber:theme-change", { detail: { theme } }),
219- );
220- } catch (error) {
221- console.warn("[dumber] Failed to apply runtime palette", error);
222- }
223- };
224-
225- // Initialize unified API object (toast functions are exposed by ToastContainer.svelte)
226- window.__dumber = window.__dumber || ({} as DumberAPI);
227-
228- // DOM zoom functionality
229- if (typeof window.__dumber_dom_zoom_level !== "number") {
230- window.__dumber_dom_zoom_level = initialZoom;
231- }
232-
233- const applyZoomStyles = (node: HTMLElement, level: number): void => {
234- if (!node) return;
235-
236- if (Math.abs(level - 1.0) < 1e-6) {
237- // Reset zoom
238- node.style.removeProperty("zoom");
239- node.style.removeProperty("transform");
240- node.style.removeProperty("transform-origin");
241- node.style.removeProperty("width");
242- node.style.removeProperty("min-width");
243- node.style.removeProperty("height");
244- node.style.removeProperty("min-height");
245- return;
246- }
247-
248- const scale = level;
249- const inversePercent = 100 / scale;
250- const widthValue = `${inversePercent}%`;
251-
252- node.style.removeProperty("zoom");
253- node.style.transform = `scale(${scale})`;
254- node.style.transformOrigin = "0 0";
255- node.style.width = widthValue;
256- node.style.minWidth = widthValue;
257- node.style.minHeight = "100%";
258- };
259-
260- window.__dumber_applyDomZoom = (level: number) => {
261- try {
262- window.__dumber_dom_zoom_level = level;
263- window.__dumber_dom_zoom_seed = level;
264- applyZoomStyles(document.documentElement, level);
265- if (document.body) {
266- applyZoomStyles(document.body, level);
267- }
268- } catch (err) {
269- console.error("[dumber] DOM zoom error", err);
270- }
271- };
272-
273- // Apply initial zoom
274- window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
275-
276- if (!document.body) {
277- document.addEventListener(
278- "DOMContentLoaded",
279- () => {
280- if (typeof window.__dumber_dom_zoom_level === "number") {
281- window.__dumber_applyDomZoom!(window.__dumber_dom_zoom_level);
282- }
283- },
284- { once: true },
285- );
286- }
287-
288- /**
289- * ============================================================================
290- * MAIN-WORLD TO ISOLATED-WORLD BRIDGE SYSTEM
291- * ============================================================================
292- *
293- * Architecture Overview:
294- * ----------------------
295- * WebKit runs scripts in different "worlds" for security:
296- *
297- * - MAIN WORLD: Where page scripts run
298- * - Can access window.webkit.messageHandlers (backend communication)
299- * - Can be accessed/modified by page JavaScript
300- * - Security risk: page can interfere with our code
301- *
302- * - ISOLATED WORLD: Where our UI components run (Svelte)
303- * - Cannot access window.webkit.messageHandlers directly
304- * - Protected from page JavaScript interference
305- * - Security benefit: page cannot read/modify our UI state
306- *
307- * Communication Patterns:
308- * -----------------------
309- *
310- * 1. WINDOW FUNCTIONS (require bridge):
311- * Backend → window.__dumber_xxx() in main world
312- * Main world → CustomEvent to isolated world
313- * Example: __dumber_omnibox_suggestions(), __dumber_favorites()
314- *
315- * 2. CUSTOMEVENT DISPATCH (no bridge needed):
316- * Backend → document.dispatchEvent() directly
317- * Both worlds can listen (shared DOM)
318- * Example: dumber:ui:shortcut (keyboard shortcuts)
319- *
320- * 3. ISOLATED TO BACKEND (reverse bridge):
321- * Isolated world → CustomEvent "dumber:isolated-message"
322- * Main world listener → window.webkit.messageHandlers.dumber
323- * Example: navigation, omnibox queries
324- *
325- * ============================================================================
326- */
327-
328- /**
329- * Unified dispatcher for forwarding main-world window calls to isolated world
330- * Only use this for window function bridges, NOT for events backend already dispatches
331- */
332- const bridgeDispatch = (eventName: string, detail: Record<string, unknown>) => {
333- try {
334- document.dispatchEvent(new CustomEvent(eventName, { detail }));
335- } catch (err) {
336- console.error(`[dumber-bridge] Failed to dispatch ${eventName}:`, err);
337- }
338- };
339-
340- // ============================================================================
341- // WINDOW FUNCTION BRIDGES
342- // These convert window.__dumber_xxx() calls to CustomEvents for isolated world
343- // ============================================================================
344-
345- // Omnibox suggestions bridge (queues suggestions until component is ready)
346- const omniboxQueue: Suggestion[] = [];
347- let omniboxReady = false;
348-
349- window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {
350- if (omniboxReady) {
351- bridgeDispatch("dumber:omnibox:suggestions", { suggestions });
352- bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support
353- } else {
354- try {
355- omniboxQueue.push(...suggestions);
356- } catch (err) {
357- console.error("[dumber-bridge] Omnibox queue error", err);
358- }
359- }
360- };
361-
362- // Inline suggestion bridge (fish-style ghost text)
363- window.__dumber_omnibox_inline_suggestion = (url: string | null) => {
364- console.log("[INLINE] main-world received inline suggestion:", url);
365- bridgeDispatch("dumber:omnibox:inline-suggestion", { url });
366- };
367-
368- // Favorites bridge
369- window.__dumber_favorites = (favorites: unknown[]) => {
370- bridgeDispatch("dumber:favorites", { favorites });
371- };
372-
373- // Toast notification bridges
374- window.__dumber_showToast = (message: string, duration?: number, type?: "info" | "success" | "error") => {
375- bridgeDispatch("dumber:toast", { message, duration, type });
376- };
377-
378- window.__dumber_showZoomToast = (level: number) => {
379- bridgeDispatch("dumber:toast:zoom", { level });
380- };
381-
382- // Handle omnibox ready signal and flush queued suggestions
383- const handleOmniboxReady = () => {
384- omniboxReady = true;
385- if (omniboxQueue && omniboxQueue.length) {
386- const items = omniboxQueue.slice();
387- omniboxQueue.length = 0;
388- items.forEach((s) => {
389- bridgeDispatch("dumber:omnibox:suggestions", { suggestions: [s] });
390- bridgeDispatch("dumber:omnibox-suggestions", { suggestions: [s] });
391- });
392- }
393- };
394-
395- document.addEventListener("dumber:omnibox-ready", handleOmniboxReady);
396- cleanupHandlers.push(() => {
397- document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);
398- });
399-
400- // Unified omnibox API for backward compatibility
401- window.__dumber.omnibox = window.__dumber.omnibox || {
402- suggestions: (suggestions: Suggestion[]) => {
403- window.__dumber_omnibox_suggestions!(suggestions);
404- },
405- };
406-
407- let popupMessageInterval: ReturnType<typeof setInterval> | null = null;
408- let oauthUrlCheckInterval: ReturnType<typeof setInterval> | null = null;
409-
410- // Popup window.opener bridge using shared localStorage
411- const setupPopupOpenerBridge = () => {
412- try {
413- // Check if this is a popup by looking for parent info in localStorage
414- const findParentPopupId = () => {
415- const keys = Object.keys(localStorage);
416- for (const key of keys) {
417- if (key.startsWith("popup_") && key.endsWith("_parent_info")) {
418- const popupId = key
419- .replace("popup_", "")
420- .replace("_parent_info", "");
421- const parentInfo = JSON.parse(localStorage.getItem(key) || "{}");
422-
423- // Check if this popup info is recent (within last 30 seconds)
424- const age = Date.now() - (parentInfo.timestamp || 0);
425- if (age < 30000) {
426- console.log(
427- `[dumber-popup] Found parent info for popup ID: ${popupId}`,
428- parentInfo,
429- );
430- return { popupId, parentInfo };
431- }
432- }
433- }
434- return null;
435- };
436-
437- const parentData = findParentPopupId();
438- if (parentData && !window.opener) {
439- const { popupId, parentInfo } = parentData;
440-
441- console.log(
442- `[dumber-popup] Setting up window.opener bridge for popup ID: ${popupId}`,
443- );
444-
445- // Create window.opener proxy that uses localStorage for communication
446- window.opener = {
447- postMessage: (data: unknown, targetOrigin?: string) => {
448- try {
449- localStorage.setItem(
450- `popup_${popupId}_message_to_parent`,
451- JSON.stringify({
452- data,
453- origin: targetOrigin || "*",
454- timestamp: Date.now(),
455- source: "popup",
456- }),
457- );
458- console.log(
459- `[dumber-popup] Sent message to parent via localStorage:`,
460- data,
461- );
462- } catch (err) {
463- console.warn(
464- `[dumber-popup] Failed to send message to parent:`,
465- err,
466- );
467- }
468- },
469-
470- focus: () => {
471- try {
472- localStorage.setItem(
473- `popup_${popupId}_parent_action`,
474- JSON.stringify({
475- action: "focus",
476- timestamp: Date.now(),
477- }),
478- );
479- } catch (err) {
480- console.warn(
481- `[dumber-popup] Failed to request parent focus:`,
482- err,
483- );
484- }
485- },
486-
487- blur: () => {
488- try {
489- localStorage.setItem(
490- `popup_${popupId}_parent_action`,
491- JSON.stringify({
492- action: "blur",
493- timestamp: Date.now(),
494- }),
495- );
496- } catch (err) {
497- console.warn(
498- `[dumber-popup] Failed to request parent blur:`,
499- err,
500- );
501- }
502- },
503-
504- location: {
505- href: parentInfo.parentUrl || "",
506- origin: new URL(parentInfo.parentUrl || "about:blank").origin,
507- },
508-
509- closed: false,
510-
511- // Support for custom properties/methods that websites might use
512- [Symbol.for("popup.bridge")]: true,
513- } as unknown as Window;
514-
515- // Set up message polling to receive messages from parent
516- const pollForParentMessages = () => {
517- try {
518- const messageKey = `popup_${popupId}_message_to_popup`;
519- const messageData = localStorage.getItem(messageKey);
520-
521- if (messageData) {
522- const { data, origin, timestamp } = JSON.parse(messageData);
523-
524- // Check if message is recent (within last 5 seconds)
525- if (Date.now() - timestamp < 5000) {
526- console.log(
527- `[dumber-popup] Received message from parent:`,
528- data,
529- );
530-
531- // Dispatch as MessageEvent to window
532- const event = new MessageEvent("message", {
533- data,
534- origin: origin || parentInfo.parentUrl || "",
535- source: window.opener,
536- });
537- window.dispatchEvent(event);
538- }
539-
540- // Clean up message
541- localStorage.removeItem(messageKey);
542- }
543- } catch (err) {
544- console.warn(
545- `[dumber-popup] Failed to poll for parent messages:`,
546- err,
547- );
548- }
549- };
550-
551- // Poll for messages every 100ms
552- popupMessageInterval = setInterval(pollForParentMessages, 100);
553- cleanupHandlers.push(() => {
554- if (popupMessageInterval) {
555- clearInterval(popupMessageInterval);
556- popupMessageInterval = null;
557- }
558- });
559-
560- console.log(
561- `[dumber-popup] window.opener bridge established successfully`,
562- );
563- }
564- } catch (err) {
565- console.warn(
566- `[dumber-popup] Failed to setup window.opener bridge:`,
567- err,
568- );
569- }
570- };
571-
572- // Setup popup bridge if this appears to be a popup
573- setupPopupOpenerBridge();
574-
575- // Parent window message polling - only for OAuth scenarios
576- let parentPollingInterval: ReturnType<typeof setInterval> | null = null;
577- let parentPollingHeartbeat: ReturnType<typeof setInterval> | null = null;
578-
579- const setupParentMessagePolling = () => {
580- // Only start polling if we have popup mappings or OAuth callbacks
581- const hasRelevantData = () => {
582- try {
583- const keys = Object.keys(localStorage);
584- return keys.some(
585- (key) =>
586- key.startsWith("popup_mapping_") ||
587- key.startsWith("oauth_callback_") ||
588- key.includes("message_to_parent"),
589- );
590- } catch {
591- return false;
592- }
593- };
594-
595- const pollForPopupMessages = () => {
596- try {
597- // Stop polling if no relevant data exists
598- if (!hasRelevantData()) {
599- return;
600- }
601-
602- const keys = Object.keys(localStorage);
603-
604- for (const key of keys) {
605- // Handle popup messages to parent
606- if (key.includes("message_to_parent")) {
607- const messageData = localStorage.getItem(key);
608- if (!messageData) continue;
609-
610- try {
611- const { data, origin, timestamp, source } =
612- JSON.parse(messageData);
613-
614- // Check if message is recent (within last 5 seconds) and from a popup
615- if (Date.now() - timestamp < 5000 && source === "popup") {
616- console.log(
617- `[dumber-parent] Received message from popup:`,
618- data,
619- );
620-
621- // Dispatch as MessageEvent to parent window
622- const event = new MessageEvent("message", {
623- data,
624- origin: origin || window.location.origin,
625- source: null, // popup reference would be here in real browser
626- });
627- window.dispatchEvent(event);
628-
629- // Clean up message
630- localStorage.removeItem(key);
631- }
632- } catch (err) {
633- console.warn(
634- `[dumber-parent] Failed to parse popup message:`,
635- err,
636- );
637- localStorage.removeItem(key); // Clean up invalid message
638- }
639- }
640-
641- // Handle OAuth callback detection from popups
642- if (key.startsWith("oauth_callback_")) {
643- const callbackData = localStorage.getItem(key);
644- if (!callbackData) continue;
645-
646- try {
647- const { url, webviewId, timestamp, isOAuthCallback } =
648- JSON.parse(callbackData);
649-
650- // Check if callback is recent (within last 10 seconds) and is an OAuth callback
651- if (Date.now() - timestamp < 10000 && isOAuthCallback) {
652- console.log(
653- `[dumber-parent] OAuth callback detected for webview ${webviewId}:`,
654- url,
655- );
656-
657- // Send close request to backend for this popup webview
658- const bridge = window.webkit?.messageHandlers?.dumber;
659- if (bridge && typeof bridge.postMessage === "function") {
660- try {
661- const closeMessage = {
662- type: "close-popup",
663- webviewId,
664- reason: "oauth-callback-success",
665- timestamp: Date.now(),
666- };
667-
668- console.log(
669- `[dumber-parent] Sending popup close request:`,
670- closeMessage,
671- );
672- bridge.postMessage(closeMessage);
673- } catch (err) {
674- console.warn(
675- `[dumber-parent] Failed to send popup close request:`,
676- err,
677- );
678- }
679- } else {
680- console.warn(
681- `[dumber-parent] No webkit bridge available for popup close request`,
682- );
683- }
684-
685- // Clean up OAuth callback data
686- localStorage.removeItem(key);
687- }
688- } catch (err) {
689- console.warn(
690- `[dumber-parent] Failed to parse OAuth callback data:`,
691- err,
692- );
693- localStorage.removeItem(key); // Clean up invalid data
694- }
695- }
696- }
697- } catch (err) {
698- console.warn(
699- `[dumber-parent] Failed to poll for popup messages:`,
700- err,
701- );
702- }
703- };
704-
705- // Start polling only when needed
706- const startPollingForOAuthCallbacks = () => {
707- if (!parentPollingInterval && hasRelevantData()) {
708- console.log(`[dumber-parent] Starting OAuth/popup message polling`);
709- parentPollingInterval = setInterval(pollForPopupMessages, 100);
710- cleanupHandlers.push(() => {
711- if (parentPollingInterval) {
712- clearInterval(parentPollingInterval);
713- parentPollingInterval = null;
714- }
715- });
716- }
717- };
718-
719- // Check periodically if polling should start
720- parentPollingHeartbeat = setInterval(() => {
721- if (hasRelevantData()) {
722- startPollingForOAuthCallbacks();
723- }
724- }, 1000);
725- cleanupHandlers.push(() => {
726- if (parentPollingHeartbeat) {
727- clearInterval(parentPollingHeartbeat);
728- parentPollingHeartbeat = null;
729- }
730- });
731- };
732-
733- // Setup conditional parent message polling
734- setupParentMessagePolling();
735-
736- // OAuth callback detection for auto-close
737- const setupOAuthCallbackDetection = () => {
738- let oauthCallbackHandled = false;
739-
740- const detectOAuthCallback = () => {
741- if (oauthCallbackHandled) {
742- return;
743- }
744-
745- const url = window.location.href.toLowerCase();
746-
747- // Check for OAuth callback patterns - be more specific to avoid false positives
748- const isCallback =
749- // OAuth callback URLs typically have these parameters
750- url.includes("code=") ||
751- url.includes("access_token=") ||
752- url.includes("id_token=") ||
753- // OAuth error responses
754- url.includes("error=access_denied") ||
755- url.includes("error=unauthorized") ||
756- // OAuth callback paths (more specific than just "callback" or "redirect")
757- url.includes("/oauth/callback") ||
758- url.includes("/auth/callback") ||
759- url.includes("oauth2callback") ||
760- url.includes("googlepopupcallback");
761-
762- if (isCallback) {
763- oauthCallbackHandled = true;
764- try {
765- // Determine which WebView should close as part of this callback
766- let targetWebViewId = window.__dumber_webview_id;
767-
768- // When the parent window detects the callback, look up its popup mapping
769- const popupMappingKey = `popup_mapping_${window.__dumber_webview_id}`;
770- try {
771- const popupMappingData = localStorage.getItem(popupMappingKey);
772- if (popupMappingData) {
773- const mapping = JSON.parse(popupMappingData);
774- const age = Date.now() - (mapping.timestamp || 0);
775- if (age < 60000 && mapping.popupId) {
776- targetWebViewId = mapping.popupId;
777- console.log(
778- `[oauth-callback] Parent detected OAuth callback, targeting popup webview: ${targetWebViewId}`,
779- );
780- localStorage.removeItem(popupMappingKey);
781- }
782- }
783- } catch (err) {
784- console.warn(
785- `[oauth-callback] Failed to resolve popup mapping:`,
786- err,
787- );
788- }
789-
790- console.log(
791- `[oauth-callback] Detected OAuth callback, closing popup webview: ${targetWebViewId}`,
792- );
793-
794- // Send close request to backend for this popup webview
795- const bridge = window.webkit?.messageHandlers?.dumber;
796- if (bridge && typeof bridge.postMessage === "function") {
797- try {
798- const closeMessage = {
799- type: "close-popup",
800- webviewId: targetWebViewId,
801- reason: "oauth-callback-success",
802- timestamp: Date.now(),
803- };
804-
805- console.log(
806- `[oauth-callback] Sending popup close request:`,
807- closeMessage,
808- );
809- bridge.postMessage(closeMessage);
810- } catch (err) {
811- console.warn(
812- `[oauth-callback] Failed to send popup close request:`,
813- err,
814- );
815- }
816- } else {
817- console.warn(
818- `[oauth-callback] No webkit bridge available for popup close request`,
819- );
820- }
821- } catch (err) {
822- console.warn(
823- `[oauth-callback] Failed to handle OAuth callback:`,
824- err,
825- );
826- }
827- }
828- };
829-
830- // Check immediately
831- detectOAuthCallback();
832-
833- // Monitor for URL changes (for SPAs that don't reload)
834- let lastUrl = window.location.href;
835- oauthUrlCheckInterval = setInterval(() => {
836- if (window.location.href !== lastUrl) {
837- lastUrl = window.location.href;
838- detectOAuthCallback();
839- }
840- }, 500);
841- cleanupHandlers.push(() => {
842- if (oauthUrlCheckInterval) {
843- clearInterval(oauthUrlCheckInterval);
844- oauthUrlCheckInterval = null;
845- }
846- });
847-
848- // Also check on navigation events
849- window.addEventListener("popstate", detectOAuthCallback);
850- window.addEventListener("hashchange", detectOAuthCallback);
851- cleanupHandlers.push(() => {
852- window.removeEventListener("popstate", detectOAuthCallback);
853- window.removeEventListener("hashchange", detectOAuthCallback);
854- });
855- };
856-
857- // Setup OAuth callback detection
858- setupOAuthCallbackDetection();
859-
860- const teardown = () => {
861- if (teardownExecuted) {
862- return;
863- }
864- teardownExecuted = true;
865-
866- while (cleanupHandlers.length) {
867- const cleanup = cleanupHandlers.pop();
868- if (!cleanup) {
869- continue;
870- }
871- try {
872- cleanup();
873- } catch (cleanupErr) {
874- console.warn("[dumber] Cleanup handler failed", cleanupErr);
875- }
876- }
877-
878- omniboxQueue.length = 0;
879- omniboxReady = false;
880-
881- window.__dumber_page_bridge_installed = false;
882- window.__dumber_teardown = undefined;
883- window.__dumber_omnibox_suggestions = undefined;
884- window.__dumber_favorites = undefined;
885- window.__dumber_showToast = undefined;
886- window.__dumber_showZoomToast = undefined;
887- window.__dumber_applyDomZoom = undefined;
888-
889- const globalState = window.__dumber;
890- if (globalState) {
891- const mutableState = globalState as unknown as Record<string, unknown>;
892- delete mutableState.omnibox;
893- }
894- };
895-
896- window.__dumber_teardown = teardown;
897- window.addEventListener("pagehide", teardown, { once: true });
898- window.addEventListener("beforeunload", teardown, { once: true });
899- cleanupHandlers.push(() => {
900- window.removeEventListener("pagehide", teardown);
901- window.removeEventListener("beforeunload", teardown);
902- window.__dumber_teardown = undefined;
903- });
904-
905- // Console capture functionality
906- const setupConsoleCapture = () => {
907- const originalConsole = {
908- log: console.log,
909- warn: console.warn,
910- error: console.error,
911- info: console.info,
912- debug: console.debug,
913- };
914-
915- const sendConsoleMessage = (level: string, args: unknown[]) => {
916- try {
917- const message = args
918- .map((arg) =>
919- typeof arg === "object" ? JSON.stringify(arg) : String(arg),
920- )
921- .join(" ");
922-
923- const formattedMessage = `${window.location.href} ${message}`;
924-
925- // Send to Go via existing message handler
926- if (window.webkit?.messageHandlers?.dumber) {
927- window.webkit.messageHandlers.dumber.postMessage({
928- type: "console-message",
929- payload: {
930- level,
931- message: formattedMessage,
932- url: window.location.href,
933- webviewId: window.__dumber_webview_id,
934- },
935- });
936- }
937- } catch {
938- // Silently ignore errors in console capture to avoid infinite loops
939- }
940- };
941-
942- const createConsoleWrapper = (
943- originalMethod: (...args: unknown[]) => void,
944- level: string,
945- ) => {
946- return function (...args: unknown[]) {
947- originalMethod.apply(console, args);
948- sendConsoleMessage(level, args);
949- };
950- };
951-
952- // Override console methods to capture messages
953- console.log = createConsoleWrapper(originalConsole.log, "LOG");
954- console.warn = createConsoleWrapper(originalConsole.warn, "WARN");
955- console.error = createConsoleWrapper(originalConsole.error, "ERROR");
956- console.info = createConsoleWrapper(originalConsole.info, "INFO");
957- console.debug = createConsoleWrapper(originalConsole.debug, "DEBUG");
958-
959- cleanupHandlers.push(() => {
960- console.log = originalConsole.log;
961- console.warn = originalConsole.warn;
962- console.error = originalConsole.error;
963- console.info = originalConsole.info;
964- console.debug = originalConsole.debug;
965- });
966- };
967-
968- // Search shortcuts response bridge (backend → isolated world)
969- window.__dumber_search_shortcuts = (shortcuts: unknown) => {
970- console.log("[dumber-bridge] Received search shortcuts from backend");
971- bridgeDispatch("dumber:search-shortcuts", { shortcuts });
972- };
973-
974- window.__dumber_search_shortcuts_error = (error: string) => {
975- console.error("[dumber-bridge] Search shortcuts error:", error);
976- };
977-
978- // Bridge for isolated world communication
979- // Listen for messages from isolated world and forward to backend via webkit.messageHandlers
980- const setupIsolatedWorldBridge = () => {
981- document.addEventListener("dumber:isolated-message", ((event: CustomEvent) => {
982- try {
983- const { payload } = event.detail;
984- if (window.webkit?.messageHandlers?.dumber) {
985- // Send object directly - Go's jscValue.ToJson() handles conversion
986- window.webkit.messageHandlers.dumber.postMessage(payload);
987- console.log("[dumber-bridge] Forwarded isolated world message:", payload.type);
988- } else {
989- console.warn("[dumber-bridge] webkit message handler not available");
990- }
991- } catch (error) {
992- console.error("[dumber-bridge] Failed to forward message:", error);
993- }
994- }) as EventListener);
995-
996- console.log("[dumber-bridge] Isolated world message bridge initialized");
997- };
998-
999- // Initialize isolated world bridge
1000- setupIsolatedWorldBridge();
1001-
1002- // Initialize console capture
1003- setupConsoleCapture();
1004-
1005- // ============================================================================
1006- // KEYBOARD AND FOCUS BLOCKING FOR OMNIBOX
1007- // ============================================================================
1008-
1009- const SHADOW_HOST_ID = 'dumber-ui-root';
1010-
1011- const isFromOmnibox = (target: EventTarget | null): boolean => {
1012- if (!target || !(target instanceof Element)) {
1013- return false;
1014- }
1015- return target.id === SHADOW_HOST_ID || target.closest(`#${SHADOW_HOST_ID}`) !== null;
1016- };
1017-
1018- window.__dumber_enableKeyboardBlocking = function(): void {
1019- if (window.__dumber_keyboard_blocking_active) {
1020- return;
1021- }
1022-
1023- window.__dumber_keyboard_blocking_active = true;
1024-
1025- window.__dumber_keyboard_blocker = function(e: Event): void {
1026- if (isFromOmnibox(e.target)) {
1027- return;
1028- }
1029- e.stopPropagation();
1030- e.stopImmediatePropagation();
1031- };
1032-
1033- window.__dumber_focus_guard = function(e: Event): void {
1034- if (e.type === 'focus') {
1035- if (!isFromOmnibox(e.target)) {
1036- e.stopPropagation();
1037- e.stopImmediatePropagation();
1038- e.preventDefault();
1039- }
1040- } else if (e.type === 'blur') {
1041- if (isFromOmnibox(e.target)) {
1042- e.stopPropagation();
1043- e.stopImmediatePropagation();
1044- e.preventDefault();
1045- setTimeout(() => {
1046- const shadowHost = document.getElementById(SHADOW_HOST_ID);
1047- if (shadowHost && shadowHost.shadowRoot && window.__dumber_keyboard_blocking_active) {
1048- const input = shadowHost.shadowRoot.querySelector('.omnibox-input-field') as HTMLInputElement;
1049- if (input) {
1050- input.focus();
1051- }
1052- }
1053- }, 0);
1054- }
1055- }
1056- };
1057-
1058- window.__dumber_click_blocker = function(e: Event): void {
1059- if (isFromOmnibox(e.target)) {
1060- return;
1061- }
1062- e.stopPropagation();
1063- e.stopImmediatePropagation();
1064- e.preventDefault();
1065- };
1066-
1067- document.addEventListener('keydown', window.__dumber_keyboard_blocker, true);
1068- document.addEventListener('keyup', window.__dumber_keyboard_blocker, true);
1069- document.addEventListener('keypress', window.__dumber_keyboard_blocker, true);
1070- document.addEventListener('focus', window.__dumber_focus_guard, true);
1071- document.addEventListener('blur', window.__dumber_focus_guard, true);
1072- document.addEventListener('click', window.__dumber_click_blocker, true);
1073- document.addEventListener('mousedown', window.__dumber_click_blocker, true);
1074- };
1075-
1076- window.__dumber_disableKeyboardBlocking = function(): void {
1077- if (!window.__dumber_keyboard_blocking_active) {
1078- return;
1079- }
1080-
1081- window.__dumber_keyboard_blocking_active = false;
1082-
1083- if (window.__dumber_keyboard_blocker) {
1084- document.removeEventListener('keydown', window.__dumber_keyboard_blocker, true);
1085- document.removeEventListener('keyup', window.__dumber_keyboard_blocker, true);
1086- document.removeEventListener('keypress', window.__dumber_keyboard_blocker, true);
1087- delete window.__dumber_keyboard_blocker;
1088- }
1089-
1090- if (window.__dumber_focus_guard) {
1091- document.removeEventListener('focus', window.__dumber_focus_guard, true);
1092- document.removeEventListener('blur', window.__dumber_focus_guard, true);
1093- delete window.__dumber_focus_guard;
1094- }
1095-
1096- if (window.__dumber_click_blocker) {
1097- document.removeEventListener('click', window.__dumber_click_blocker, true);
1098- document.removeEventListener('mousedown', window.__dumber_click_blocker, true);
1099- delete window.__dumber_click_blocker;
1100- }
1101- };
1102- } catch (err) {
1103- console.warn("[dumber] unified bridge init failed", err);
1104- }
1105-})();
D webui/src/injected/modules/color-scheme.ts +0 -268
@@ -1,268 +0,0 @@
1-/**
2- * Color Scheme Detection Module
3- *
4- * Handles GTK theme detection and applies appropriate color scheme preferences
5- * to web pages. This module is injected at document-start to ensure proper
6- * theme initialization before page content loads.
7- */
8-
9-declare global {
10- interface Window {
11- __dumber_gtk_prefers_dark?: boolean;
12- __dumber_setTheme?: (theme: "light" | "dark") => void;
13- }
14-}
15-
16-interface ColorSchemeConfig {
17- isDark: boolean;
18- colorScheme: "light" | "dark";
19- metaContent: string;
20- rootStyle: string;
21-}
22-
23-class ColorSchemeManager {
24- private config: ColorSchemeConfig;
25- private metaElement: HTMLMetaElement | null = null;
26- private styleElement: HTMLStyleElement | null = null;
27- private userPreference: "light" | "dark" | null = null;
28- private static readonly STORAGE_KEY = "dumber.theme";
29- private static instance: ColorSchemeManager | null = null;
30- private static matchMediaPatched = false;
31- private static originalMatchMedia: typeof window.matchMedia | null = null;
32-
33- constructor() {
34- ColorSchemeManager.instance = this;
35-
36- this.userPreference = this.getStoredPreference();
37-
38- // Use GTK preference if available, otherwise detect from system
39- const prefersDark = this.userPreference
40- ? this.userPreference === "dark"
41- : (window.__dumber_gtk_prefers_dark ?? false);
42-
43- this.config = {
44- isDark: prefersDark,
45- colorScheme: prefersDark ? "dark" : "light",
46- metaContent: prefersDark ? "dark light" : "light dark",
47- rootStyle: prefersDark
48- ? ":root{color-scheme:dark;}"
49- : ":root{color-scheme:light;}",
50- };
51-
52- this.initialize();
53- }
54-
55- private initialize(): void {
56- try {
57- console.log(`[dumber] color-scheme set: ${this.config.colorScheme}`);
58- console.log(
59- `[dumber] GTK detected color mode: ${this.config.colorScheme}`,
60- );
61-
62- // Notify native layer of theme preference
63- this.notifyNativeLayer();
64-
65- // Inject color-scheme meta tag
66- this.injectColorSchemeMeta();
67-
68- // Apply root styles
69- this.applyRootStyles();
70-
71- // Override matchMedia for proper prefers-color-scheme support
72- this.overrideMatchMedia();
73-
74- // Set up theme integration with existing UI components
75- this.setupThemeIntegration();
76- } catch (error) {
77- console.warn("[dumber] color-scheme injection failed", error);
78- }
79- }
80-
81- private notifyNativeLayer(): void {
82- try {
83- (window as any).webkit?.messageHandlers?.dumber?.postMessage({
84- type: "theme",
85- payload: { value: this.config.colorScheme },
86- });
87- } catch {
88- // Ignore errors - native layer may not be available
89- }
90- }
91-
92- private injectColorSchemeMeta(): void {
93- if (this.metaElement && this.metaElement.isConnected) {
94- this.metaElement.content = this.config.metaContent;
95- return;
96- }
97-
98- const existing = document.querySelector(
99- 'meta[name="color-scheme"]',
100- ) as HTMLMetaElement | null;
101- const meta = existing ?? document.createElement("meta");
102- meta.name = "color-scheme";
103- meta.content = this.config.metaContent;
104- if (!existing) {
105- document.documentElement.appendChild(meta);
106- }
107- this.metaElement = meta;
108- }
109-
110- private applyRootStyles(): void {
111- if (this.styleElement && this.styleElement.isConnected) {
112- this.styleElement.textContent = this.config.rootStyle;
113- return;
114- }
115-
116- const existing = document.querySelector(
117- "style[data-dumber-color-scheme]",
118- ) as HTMLStyleElement | null;
119- const style = existing ?? document.createElement("style");
120- style.setAttribute("data-dumber-color-scheme", "");
121- style.textContent = this.config.rootStyle;
122- if (!existing) {
123- document.documentElement.appendChild(style);
124- }
125- this.styleElement = style;
126- }
127-
128- private overrideMatchMedia(): void {
129- if (ColorSchemeManager.matchMediaPatched) {
130- return;
131- }
132-
133- const darkQuery = "(prefers-color-scheme: dark)";
134- const lightQuery = "(prefers-color-scheme: light)";
135- ColorSchemeManager.originalMatchMedia = window.matchMedia.bind(window);
136-
137- window.matchMedia = (query: string): MediaQueryList => {
138- const manager = ColorSchemeManager.instance;
139- if (
140- manager &&
141- typeof query === "string" &&
142- (query.includes(darkQuery) || query.includes(lightQuery))
143- ) {
144- const matches = query.includes("dark")
145- ? manager.config.isDark
146- : !manager.config.isDark;
147-
148- return {
149- matches,
150- media: query,
151- onchange: null,
152- addListener: () => {},
153- removeListener: () => {},
154- addEventListener: () => {},
155- removeEventListener: () => {},
156- dispatchEvent: () => false,
157- } as MediaQueryList;
158- }
159-
160- const original = ColorSchemeManager.originalMatchMedia;
161- if (!original) {
162- throw new Error("Original matchMedia not available");
163- }
164-
165- return original.call(window, query);
166- };
167-
168- ColorSchemeManager.matchMediaPatched = true;
169- }
170-
171- private setupThemeIntegration(): void {
172- // Integrate with unified theme setter if available
173- if (window.__dumber_setTheme) {
174- window.__dumber_setTheme(this.config.colorScheme);
175- } else {
176- // Retry after a short delay in case the bridge hasn't loaded yet
177- setTimeout(() => {
178- if (window.__dumber_setTheme) {
179- window.__dumber_setTheme(this.config.colorScheme);
180- } else {
181- // Fallback: directly apply dark class for Tailwind compatibility
182- console.warn("[dumber] Theme setter not available, using fallback");
183- if (this.config.isDark) {
184- document.documentElement.classList.add("dark");
185- } else {
186- document.documentElement.classList.remove("dark");
187- }
188- try {
189- window.__dumber_applyPalette?.(
190- this.config.isDark ? "dark" : "light",
191- );
192- } catch {
193- /* noop */
194- }
195- }
196- }, 100);
197- }
198- }
199-
200- /**
201- * Updates the color scheme preference at runtime
202- * @param isDark Whether to use dark mode
203- */
204- public updateColorScheme(isDark: boolean): void {
205- this.config.isDark = isDark;
206- this.config.colorScheme = isDark ? "dark" : "light";
207- this.config.metaContent = isDark ? "dark light" : "light dark";
208- this.config.rootStyle = isDark
209- ? ":root{color-scheme:dark;}"
210- : ":root{color-scheme:light;}";
211-
212- // Re-apply all configurations
213- this.initialize();
214- }
215-
216- private getStoredPreference(): "light" | "dark" | null {
217- try {
218- const value = localStorage.getItem(ColorSchemeManager.STORAGE_KEY);
219- if (value === "light" || value === "dark") {
220- return value;
221- }
222- } catch (error) {
223- console.warn("[dumber] Failed to read stored theme preference", error);
224- }
225- return null;
226- }
227-
228- private storePreference(theme: "light" | "dark"): void {
229- try {
230- localStorage.setItem(ColorSchemeManager.STORAGE_KEY, theme);
231- } catch (error) {
232- console.warn("[dumber] Failed to persist theme preference", error);
233- }
234- }
235-
236- /**
237- * Persist and apply an explicit user preference
238- */
239- public setUserPreference(theme: "light" | "dark"): void {
240- if (this.userPreference === theme && this.config.colorScheme === theme) {
241- return;
242- }
243- this.userPreference = theme;
244- this.storePreference(theme);
245- this.updateColorScheme(theme === "dark");
246- }
247-
248- public getCurrentTheme(): "light" | "dark" {
249- return this.config.colorScheme;
250- }
251-}
252-
253-// Initialize color scheme management
254-const colorSchemeManager = new ColorSchemeManager();
255-
256-// Export for potential runtime updates
257-(window as any).__dumber_color_scheme_manager = colorSchemeManager;
258-
259-export default colorSchemeManager;
260-
261-declare global {
262- interface Window {
263- __dumber_color_scheme_manager?: {
264- setUserPreference: (theme: "light" | "dark") => void;
265- getCurrentTheme: () => "light" | "dark";
266- };
267- }
268-}
D webui/src/injected/modules/omnibox.ts +0 -187
@@ -1,187 +0,0 @@
1-/**
2- * Omnibox Module Initialization
3- *
4- * Initializes the Svelte 5 omnibox component system
5- */
6-
7-import { mount } from "svelte";
8-import { Omnibox } from "../../components/omnibox";
9-import { ensureShadowMount } from "./shadowHost";
10-import type { OmniboxConfig } from "../../components/omnibox/types";
11-import { omniboxBridge } from "../../components/omnibox/messaging";
12-
13-let omniboxComponent: ReturnType<typeof mount> | null = null;
14-let isInitialized = false;
15-
16-export interface OmniboxInitConfig extends OmniboxConfig {
17- // Additional initialization options if needed
18- containerId?: string;
19-}
20-
21-/**
22- * Initialize the omnibox component system
23- */
24-export async function initializeOmnibox(
25- _config: OmniboxInitConfig = {},
26-): Promise<void> {
27- if (isInitialized) {
28- console.warn("Omnibox already initialized");
29- return;
30- }
31-
32- try {
33- // Ensure we're in the main frame (not an iframe)
34- if (window.self !== window.top) {
35- console.log("🚫 Omnibox skipped in iframe");
36- return;
37- }
38-
39- // Ensure a <body> exists before mounting (document-start injection can run before it's available)
40- if (!document.body) {
41- await new Promise<void>((resolve) => {
42- let resolved = false;
43- const finish = () => {
44- if (!resolved) {
45- resolved = true;
46- resolve();
47- }
48- };
49- const check = () => {
50- if (document.body) {
51- finish();
52- } else {
53- setTimeout(check, 10);
54- }
55- };
56- document.addEventListener("DOMContentLoaded", finish, { once: true });
57- check();
58- });
59- }
60-
61- // Mount omnibox into the shared global Shadow DOM host
62- const mountEl = ensureShadowMount("dumber-omnibox");
63- omniboxComponent = mount(Omnibox, {
64- target: mountEl as unknown as Element,
65- });
66-
67- // Wait for the component to mount and set up the global API
68- await new Promise<void>((resolve) => {
69- const checkAPI = () => {
70- if (
71- window.__dumber_omnibox &&
72- typeof window.__dumber_omnibox === "object"
73- ) {
74- console.log("✅ Omnibox component system initialized");
75- console.log(
76- "🔧 Global API is available:",
77- Object.keys(window.__dumber_omnibox),
78- );
79- isInitialized = true;
80-
81- // Fetch search shortcuts from backend
82- omniboxBridge.fetchSearchShortcuts().catch((err) => {
83- console.warn("Failed to fetch search shortcuts:", err);
84- });
85-
86- // Notify isolated bundle listeners that omnibox is ready
87- try {
88- document.dispatchEvent(new CustomEvent("dumber:omnibox-ready"));
89- } catch (e) {
90- console.warn("Failed to dispatch omnibox-ready event", e);
91- }
92- resolve();
93- } else {
94- setTimeout(checkAPI, 10); // Check again in 10ms
95- }
96- };
97- checkAPI();
98- });
99-
100- // Bridge: listen for page-world events carrying suggestions and forward to API
101- const handleSuggestionsEvent = (e: Event) => {
102- try {
103- const detail = (e as CustomEvent).detail;
104- const suggestions = detail?.suggestions ?? detail;
105- if (
106- Array.isArray(suggestions) &&
107- window.__dumber_omnibox?.setSuggestions
108- ) {
109- console.log(
110- "🔗 [Bridge] Received page-world suggestions event:",
111- suggestions.length,
112- );
113- window.__dumber_omnibox.setSuggestions(suggestions);
114- } else {
115- console.warn(
116- "🔗 [Bridge] Suggestions event received but API not ready or invalid payload",
117- );
118- }
119- } catch (err) {
120- console.warn("🔗 [Bridge] Failed handling suggestions event:", err);
121- }
122- };
123-
124- document.addEventListener(
125- "dumber:omnibox-suggestions",
126- handleSuggestionsEvent,
127- false,
128- );
129- // Unified event name
130- document.addEventListener(
131- "dumber:omnibox:suggestions",
132- handleSuggestionsEvent,
133- false,
134- );
135-
136- // Bridge: listen for inline suggestion events (fish-style ghost text)
137- const handleInlineSuggestionEvent = (e: Event) => {
138- try {
139- const detail = (e as CustomEvent).detail;
140- const url = detail?.url ?? null;
141- console.log("[INLINE] omnibox module received event:", url);
142- if (window.__dumber_omnibox?.setInlineSuggestion) {
143- window.__dumber_omnibox.setInlineSuggestion(url);
144- } else {
145- console.warn("[INLINE] __dumber_omnibox.setInlineSuggestion not available");
146- }
147- } catch (err) {
148- console.warn("[Bridge] Failed handling inline suggestion event:", err);
149- }
150- };
151-
152- document.addEventListener(
153- "dumber:omnibox:inline-suggestion",
154- handleInlineSuggestionEvent,
155- false,
156- );
157- } catch (error) {
158- console.error("❌ Failed to initialize omnibox component:", error);
159- throw error;
160- }
161-}
162-
163-/**
164- * Get the current initialization status
165- */
166-export function isOmniboxInitialized(): boolean {
167- return isInitialized;
168-}
169-
170-/**
171- * Cleanup omnibox component (useful for hot reload during development)
172- */
173-export function cleanupOmnibox(): void {
174- if (omniboxComponent) {
175- try {
176- omniboxComponent.$destroy?.();
177- } catch (error) {
178- console.warn("Error destroying omnibox component:", error);
179- }
180- omniboxComponent = null;
181- }
182-
183- // The shared shadow host remains persistent; just log cleanup
184-
185- isInitialized = false;
186- console.log("🧹 Omnibox component cleaned up");
187-}
D webui/src/injected/modules/shadowHost.ts +0 -252
@@ -1,252 +0,0 @@
1-/**
2- * Global Shadow DOM Host Utility
3- *
4- * Ensures a single, shared ShadowRoot is created and reused for all injected UI components
5- * (omnibox, toast, etc.), with a minimal CSS reset applied once.
6- */
7-
8-// Import the compiled Tailwind bundle as a string so we can inject it
9-// directly into the shadow root. The ?inline suffix makes Vite embed the
10-// processed CSS, ensuring all utility classes are available without relying
11-// on runtime @import resolution (which fails inside the injected shadow DOM).
12-import globalStyles from "../../styles/tailwind.css?inline";
13-
14-// Track shadow-root initialization to avoid duplicate reset injection
15-const shadowResetApplied = new WeakSet<ShadowRoot>();
16-
17-const HOST_STYLES = `
18- position: fixed !important;
19- top: 0 !important;
20- left: 0 !important;
21- right: 0 !important;
22- bottom: 0 !important;
23- z-index: 2147483647 !important;
24- pointer-events: none !important;
25- margin: 0 !important;
26- padding: 0 !important;
27- border: none !important;
28- background: none !important;
29- isolation: isolate !important;
30- contain: layout style !important;
31- `;
32-
33-let cachedHost: HTMLElement | null = null;
34-let hostObserver: MutationObserver | null = null;
35-let hostKeepaliveTimer: number | null = null;
36-
37-function ensureHostPresence(host: HTMLElement): void {
38- const docEl = document.documentElement;
39- if (docEl && !host.isConnected) {
40- docEl.appendChild(host);
41- }
42-
43- if (typeof MutationObserver !== "undefined") {
44- if (!hostObserver) {
45- hostObserver = new MutationObserver(() => {
46- if (cachedHost && !cachedHost.isConnected && document.documentElement) {
47- document.documentElement.appendChild(cachedHost);
48- }
49- });
50- }
51-
52- try {
53- hostObserver.disconnect();
54- if (docEl) {
55- hostObserver.observe(docEl, { childList: true });
56- }
57- } catch {
58- // MutationObserver might not be allowed by the page; fall back to polling in that case.
59- }
60- }
61-
62- if (hostKeepaliveTimer === null && typeof window !== "undefined") {
63- hostKeepaliveTimer = window.setInterval(() => {
64- if (cachedHost && !cachedHost.isConnected && document.documentElement) {
65- document.documentElement.appendChild(cachedHost);
66- }
67- }, 2000);
68- }
69-}
70-
71-export const GLOBAL_SHADOW_HOST_ID = "dumber-ui-root";
72-const TOKEN_KEYS = [
73- "--color-browser-bg",
74- "--color-browser-surface",
75- "--color-browser-surface-variant",
76- "--color-browser-text",
77- "--color-browser-muted",
78- "--color-browser-accent",
79- "--color-browser-border",
80- "--dynamic-bg",
81- "--dynamic-surface",
82- "--dynamic-surface-variant",
83- "--dynamic-text",
84- "--dynamic-muted",
85- "--dynamic-accent",
86- "--dynamic-border",
87-] as const;
88-
89-function syncHostTokens(host: HTMLElement) {
90- if (typeof window === "undefined" || typeof document === "undefined") {
91- return;
92- }
93-
94- try {
95- const root = document.documentElement;
96- const computed = getComputedStyle(root);
97- for (const token of TOKEN_KEYS) {
98- const value = computed.getPropertyValue(token);
99- if (value && value.trim()) {
100- host.style.setProperty(token, value.trim());
101- }
102- }
103- } catch (error) {
104- console.warn("[shadowHost] Failed to sync color tokens", error);
105- }
106-}
107-
108-/**
109- * Ensure and return the global ShadowRoot used by injected UI.
110- */
111-export function getGlobalShadowRoot(): ShadowRoot {
112- // Create or find the host container
113- let host = document.getElementById(
114- GLOBAL_SHADOW_HOST_ID,
115- ) as HTMLElement | null;
116- if (!host) {
117- host = document.createElement("div");
118- host.id = GLOBAL_SHADOW_HOST_ID;
119- }
120-
121- host.style.cssText = HOST_STYLES;
122-
123- cachedHost = host;
124- ensureHostPresence(host);
125- syncHostTokens(host);
126-
127- const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: "open" });
128-
129- // Reflect current theme from document root to the shadow host so :host(.dark) works
130- try {
131- const isDark = document.documentElement.classList.contains("dark");
132- host.classList.toggle("dark", isDark);
133- // Keep it in sync if the theme toggles later
134- const observer = new MutationObserver(() => {
135- host.classList.toggle(
136- "dark",
137- document.documentElement.classList.contains("dark"),
138- );
139- syncHostTokens(host);
140- });
141- observer.observe(document.documentElement, {
142- attributes: true,
143- attributeFilter: ["class"],
144- });
145- } catch {
146- /* no-op */
147- }
148-
149- try {
150- document.addEventListener("dumber:palette-updated", () => {
151- if (cachedHost) {
152- syncHostTokens(cachedHost);
153- }
154- });
155- } catch {
156- /* no-op */
157- }
158-
159- // Inject a minimal reset and base styles into the shadow root once
160- if (!shadowResetApplied.has(shadowRoot)) {
161- const resetStyle = document.createElement("style");
162- resetStyle.textContent = `
163- :host { all: initial; }
164- *, *::before, *::after { box-sizing: border-box; }
165- :host { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; }
166- `;
167- shadowRoot.appendChild(resetStyle);
168-
169- // Ensure dynamic design tokens exist within the shadow tree. Tailwind v4
170- // utilities rely on these CSS variables. We set light defaults and a dark variant on :host.
171- const tokensStyle = document.createElement("style");
172- tokensStyle.textContent = `
173- :host {
174- --dynamic-bg: var(--color-browser-bg);
175- --dynamic-surface-variant: var(--color-browser-surface-variant);
176- --dynamic-surface: var(--color-browser-surface);
177- --dynamic-text: var(--color-browser-text);
178- --dynamic-muted: var(--color-browser-muted);
179- --dynamic-accent: var(--color-browser-accent);
180- --dynamic-border: var(--color-browser-border);
181- }
182- `;
183- shadowRoot.appendChild(tokensStyle);
184-
185- // Inject the global GUI stylesheet (Tailwind) into shadow root
186- try {
187- // Use <style> tag injection for Tailwind global styles
188- const styleTag = document.createElement("style");
189- styleTag.setAttribute("data-source", "dumber-tailwind-styles");
190- styleTag.textContent = globalStyles;
191- shadowRoot.appendChild(styleTag);
192-
193- // Inject component styles from Go-provided string
194- // These are Svelte component-scoped styles that must be inside the shadow root
195- // due to Shadow DOM style encapsulation
196- const componentStyles = (window as any).__dumber_component_styles;
197- if (componentStyles) {
198- const componentStyleTag = document.createElement("style");
199- componentStyleTag.setAttribute("data-source", "dumber-component-styles");
200- componentStyleTag.textContent = componentStyles;
201- shadowRoot.appendChild(componentStyleTag);
202- console.log(
203- "[shadowHost] Component styles injected:",
204- componentStyles.length,
205- "chars",
206- );
207- } else {
208- console.warn("[shadowHost] Component styles not available on window");
209- }
210-
211- console.log(
212- "[shadowHost] All styles injected, shadow root children:",
213- shadowRoot.childNodes.length,
214- );
215- } catch (error) {
216- console.error("[shadowHost] Failed to inject styles:", error);
217- // Fallback attempt
218- try {
219- const fallbackStyle = document.createElement("style");
220- fallbackStyle.textContent = globalStyles || "";
221- shadowRoot.appendChild(fallbackStyle);
222- } catch (fallbackError) {
223- console.error(
224- "[shadowHost] Fallback style injection also failed:",
225- fallbackError,
226- );
227- }
228- }
229- shadowResetApplied.add(shadowRoot);
230- }
231-
232- return shadowRoot;
233-}
234-
235-/**
236- * Ensure and return a dedicated mount element inside the global ShadowRoot.
237- * Use a unique id per feature (e.g., "dumber-omnibox", "dumber-toast").
238- */
239-export function ensureShadowMount(mountId: string): HTMLElement {
240- const root = getGlobalShadowRoot();
241- let mount = root.getElementById?.(mountId) as HTMLElement | null;
242- if (!mount) {
243- mount = document.createElement("div");
244- mount.id = mountId;
245- mount.style.cssText = `
246- position: relative;
247- pointer-events: none; /* components inside control their own pointer events */
248- `;
249- root.appendChild(mount);
250- }
251- return mount;
252-}
D webui/src/injected/modules/toast.ts +0 -113
@@ -1,113 +0,0 @@
1-/**
2- * Toast System Module
3- *
4- * Provides toast notification functionality using Svelte 5 components
5- * with proper DOM ready checking and theme support.
6- */
7-
8-import { mount, flushSync } from "svelte";
9-import ToastContainer from "$components/toast/ToastContainer.svelte";
10-import { ensureShadowMount } from "./shadowHost";
11-
12-export interface ToastConfig {
13- theme?: "light" | "dark";
14-}
15-
16-// Type definitions for window globals
17-declare global {
18- interface Window {
19- __dumber_toast_loaded?: boolean;
20- __dumber_initial_theme?: string;
21- }
22-}
23-
24-let toastInitialized = false;
25-
26-// Apply theme based on current system/GTK preference
27-function updateTheme(isDark: boolean): void {
28- if (isDark) {
29- document.documentElement.classList.add("dark");
30- } else {
31- document.documentElement.classList.remove("dark");
32- }
33-}
34-
35-export async function initializeToast(config?: ToastConfig): Promise<void> {
36- if (toastInitialized || window.__dumber_toast_loaded) {
37- // Verify functions still exist - if not, reinitialize
38- if (
39- typeof window.__dumber_showToast === "function" &&
40- typeof window.__dumber_showZoomToast === "function"
41- ) {
42- console.log("✅ Toast system already initialized and functional");
43- return;
44- }
45- // If functions are missing, reinitialize
46- console.log("⚠️ Toast functions missing, reinitializing...");
47- toastInitialized = false;
48- window.__dumber_toast_loaded = false;
49- }
50-
51- console.log("🚀 Initializing Svelte toast system at:", document.readyState);
52- console.log(
53- "📊 DOM state check - head:",
54- !!document.head,
55- "body:",
56- !!document.body,
57- );
58- window.__dumber_toast_loaded = true;
59-
60- // Ensure DOM is ready
61- if (document.readyState === "loading") {
62- return new Promise((resolve) => {
63- document.addEventListener("DOMContentLoaded", async () => {
64- await initializeToast(config);
65- resolve();
66- });
67- });
68- }
69-
70- if (!document.head || !document.body) {
71- console.warn(
72- "❌ DOM structure not ready, deferring toast initialization. Retrying...",
73- );
74- window.__dumber_toast_loaded = false; // Reset the flag so we can try again
75- return new Promise((resolve) => {
76- const checkDOM = () => {
77- if (document.head && document.body) {
78- console.log("✅ DOM structure now ready, initializing toast system");
79- initializeToast(config).then(() => resolve());
80- } else {
81- console.log("⏳ DOM still not ready, retrying in 50ms...");
82- setTimeout(checkDOM, 50);
83- }
84- };
85- setTimeout(checkDOM, 100);
86- });
87- }
88-
89- try {
90- // Use the global Shadow DOM host for isolation
91- const rootElement = ensureShadowMount("dumber-toast");
92-
93- // Initialize theme
94- const initialTheme = config?.theme || window.__dumber_initial_theme;
95- if (initialTheme !== undefined) {
96- updateTheme(initialTheme === "dark");
97- }
98-
99- // Mount the Svelte toast container - this exposes all toast functions to window
100- mount(ToastContainer, {
101- target: rootElement as unknown as Element,
102- });
103-
104- // Force immediate effect execution to ensure component functions are exposed
105- flushSync();
106-
107- toastInitialized = true;
108- console.log("✅ Toast system initialized - functions exposed by ToastContainer");
109- } catch (error) {
110- console.error("❌ Failed to initialize toast system:", error);
111- throw error;
112- }
113-}
D webui/src/injected/modules/workspace.ts +0 -8
@@ -1,8 +0,0 @@
1-/**
2- * Workspace UI Notifications Module
3- *
4- * Pane mode is now handled entirely in the Go backend with visual border feedback.
5- * This module is kept for potential future UI enhancements.
6- */
7-
8-console.log("[workspace] UI notification handlers initialized");
D webui/src/injected/styles-entry.css +0 -2
@@ -1,2 +0,0 @@
1-/* Vite/PostCSS/Tailwind processed CSS entry for Shadow DOM injection */
2-@import "../lib/styles.css";
D webui/src/lib/keyboard/KeyboardService.ts +0 -432
@@ -1,432 +0,0 @@
1-/**
2- * Keyboard Service
3- *
4- * Centralized keyboard shortcut management for the Dumber Browser GUI.
5- * Bridges between native GTK keyboard events and Svelte component handlers.
6- */
7-
8-import type {
9- ShortcutConfig,
10- ComponentShortcuts,
11- KeyboardServiceConfig,
12- KeyboardEventDetail,
13- KeyboardEvent,
14- KeyboardEventType,
15-} from "./types";
16-// COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers
17-
18-export class KeyboardService {
19- private components = new Map<string, ComponentShortcuts>();
20- private activeComponent: string | null = null;
21- private globalShortcuts: ShortcutConfig[] = [];
22- private eventListeners = new Map<
23- KeyboardEventType,
24- Set<(event: KeyboardEvent) => void>
25- >();
26- private debug: boolean = false;
27- // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now
28-
29- constructor(config: KeyboardServiceConfig = {}) {
30- this.debug = config.debug ?? false;
31- this.globalShortcuts = config.globalShortcuts ?? [];
32-
33- this.log("info", "KeyboardService initialized", { config });
34- }
35-
36- /**
37- * Register shortcuts for a component
38- */
39- registerShortcuts(componentId: string, shortcuts: ShortcutConfig[]): void {
40- if (this.components.has(componentId)) {
41- this.log(
42- "warn",
43- `Component ${componentId} already registered, overwriting shortcuts`,
44- );
45- }
46-
47- const componentShortcuts: ComponentShortcuts = {
48- componentId,
49- shortcuts: shortcuts.map((shortcut) => ({
50- ...shortcut,
51- preventDefault: shortcut.preventDefault ?? true,
52- stopPropagation: shortcut.stopPropagation ?? true,
53- whenFocused: shortcut.whenFocused ?? false,
54- })),
55- focused: false,
56- };
57-
58- this.components.set(componentId, componentShortcuts);
59-
60- this.log("info", "Shortcuts registered", {
61- componentId,
62- shortcuts: shortcuts.length,
63- });
64- this.emit("shortcut-registered", {
65- shortcut: componentId,
66- timestamp: Date.now(),
67- targetComponent: componentId,
68- });
69- }
70-
71- /**
72- * Unregister shortcuts for a component
73- */
74- unregisterShortcuts(componentId: string): void {
75- const component = this.components.get(componentId);
76- if (!component) {
77- this.log("warn", `Component ${componentId} not found for unregistration`);
78- return;
79- }
80-
81- // Call cleanup function if provided
82- if (component.cleanup) {
83- component.cleanup();
84- }
85-
86- this.components.delete(componentId);
87-
88- // Clear active component if it was this one
89- if (this.activeComponent === componentId) {
90- this.activeComponent = null;
91- }
92-
93- this.log("info", "Shortcuts unregistered", { componentId });
94- this.emit("shortcut-unregistered", {
95- shortcut: componentId,
96- timestamp: Date.now(),
97- targetComponent: componentId,
98- });
99- }
100-
101- /**
102- * Set which component is currently focused
103- */
104- setActiveComponent(componentId: string | null): void {
105- // Blur previous component
106- if (this.activeComponent && this.components.has(this.activeComponent)) {
107- const prevComponent = this.components.get(this.activeComponent)!;
108- prevComponent.focused = false;
109- this.emit("component-blurred", {
110- shortcut: this.activeComponent,
111- timestamp: Date.now(),
112- targetComponent: this.activeComponent,
113- });
114- }
115-
116- this.activeComponent = componentId;
117-
118- // Focus new component
119- if (componentId && this.components.has(componentId)) {
120- const newComponent = this.components.get(componentId)!;
121- newComponent.focused = true;
122- this.emit("component-focused", {
123- shortcut: componentId,
124- timestamp: Date.now(),
125- targetComponent: componentId,
126- });
127- }
128-
129- this.log("info", "Active component changed", {
130- from: this.activeComponent,
131- to: componentId,
132- });
133- }
134-
135- /**
136- * Handle a keyboard shortcut from native GTK layer
137- */
138- handleNativeShortcut(shortcut: string): boolean {
139- this.log("info", "Native shortcut received", { shortcut });
140-
141- // Normalize shortcut string
142- const normalizedShortcut = this.normalizeShortcut(shortcut);
143-
144- // Try to handle with focused component first
145- if (this.activeComponent) {
146- const handled = this.handleComponentShortcut(
147- this.activeComponent,
148- normalizedShortcut,
149- true,
150- );
151- if (handled) {
152- return true;
153- }
154- }
155-
156- // Try global shortcuts
157- const globalHandled = this.handleGlobalShortcuts(normalizedShortcut);
158- if (globalHandled) {
159- return true;
160- }
161-
162- // Try all components (for non-focused shortcuts)
163- for (const [componentId] of this.components) {
164- if (componentId === this.activeComponent) continue; // Already tried above
165-
166- const handled = this.handleComponentShortcut(
167- componentId,
168- normalizedShortcut,
169- false,
170- );
171- if (handled) {
172- return true;
173- }
174- }
175-
176- this.log("info", "Shortcut not handled", { shortcut: normalizedShortcut });
177- return false;
178- }
179-
180- /**
181- * Handle JavaScript keyboard events (for web page events)
182- */
183- handleKeyboardEvent(event: globalThis.KeyboardEvent): boolean {
184- const shortcut = this.eventToShortcut(event);
185- if (!shortcut) return false;
186-
187- this.log("info", "Keyboard event received", { shortcut });
188- return this.handleNativeShortcut(shortcut);
189- }
190-
191- /**
192- * Handle JavaScript mouse events (for navigation buttons)
193- */
194- handleMouseEvent(event: globalThis.MouseEvent): boolean {
195- try {
196- // Mouse button 3 (back button)
197- if (event.button === 3) {
198- event.preventDefault();
199- event.stopPropagation();
200-
201- if (window.history.length > 1) {
202- console.log("⬅️ Mouse back button pressed");
203- window.history.back();
204- }
205- return true;
206- }
207-
208- // Mouse button 4 (forward button)
209- if (event.button === 4) {
210- event.preventDefault();
211- event.stopPropagation();
212-
213- console.log("➡️ Mouse forward button pressed");
214- window.history.forward();
215- return true;
216- }
217- } catch (error) {
218- this.log("error", "Mouse handler error", { error });
219- }
220-
221- return false;
222- }
223-
224- /**
225- * Add event listener for keyboard service events
226- */
227- addEventListener(
228- type: KeyboardEventType,
229- listener: (event: KeyboardEvent) => void,
230- ): void {
231- if (!this.eventListeners.has(type)) {
232- this.eventListeners.set(type, new Set());
233- }
234- this.eventListeners.get(type)!.add(listener);
235- }
236-
237- /**
238- * Remove event listener
239- */
240- removeEventListener(
241- type: KeyboardEventType,
242- listener: (event: KeyboardEvent) => void,
243- ): void {
244- const listeners = this.eventListeners.get(type);
245- if (listeners) {
246- listeners.delete(listener);
247- }
248- }
249-
250- /**
251- * Get all registered shortcuts for debugging
252- */
253- getDebugInfo(): object {
254- return {
255- components: Array.from(this.components.entries()).map(([id, comp]) => ({
256- id,
257- shortcuts: comp.shortcuts.length,
258- focused: comp.focused,
259- })),
260- activeComponent: this.activeComponent,
261- globalShortcuts: this.globalShortcuts.length,
262- };
263- }
264-
265- // Private methods
266-
267- private handleComponentShortcut(
268- componentId: string,
269- shortcut: string,
270- respectFocus: boolean,
271- ): boolean {
272- const component = this.components.get(componentId);
273- if (!component) return false;
274-
275- for (const shortcutConfig of component.shortcuts) {
276- if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
277- // Check focus requirement
278- if (respectFocus && shortcutConfig.whenFocused && !component.focused) {
279- continue;
280- }
281-
282- try {
283- shortcutConfig.handler();
284- this.log("info", "Shortcut handled", {
285- shortcut,
286- componentId,
287- focused: component.focused,
288- description: shortcutConfig.description,
289- });
290-
291- this.emit("shortcut-triggered", {
292- shortcut,
293- timestamp: Date.now(),
294- targetComponent: componentId,
295- });
296-
297- return true;
298- } catch (error) {
299- this.log("error", "Shortcut handler error", {
300- shortcut,
301- componentId,
302- error,
303- });
304- }
305- }
306- }
307-
308- return false;
309- }
310-
311- private handleGlobalShortcuts(shortcut: string): boolean {
312- for (const shortcutConfig of this.globalShortcuts) {
313- if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
314- try {
315- shortcutConfig.handler();
316- this.log("info", "Global shortcut handled", {
317- shortcut,
318- description: shortcutConfig.description,
319- });
320-
321- this.emit("shortcut-triggered", {
322- shortcut,
323- timestamp: Date.now(),
324- });
325-
326- return true;
327- } catch (error) {
328- this.log("error", "Global shortcut handler error", {
329- shortcut,
330- error,
331- });
332- }
333- }
334- }
335- return false;
336- }
337-
338- private normalizeShortcut(shortcut: string): string {
339- return shortcut.toLowerCase().replace(/\s+/g, "");
340- }
341-
342- private eventToShortcut(event: globalThis.KeyboardEvent): string | null {
343- const parts: string[] = [];
344-
345- // Add modifiers using MODIFIER_KEYS mapping
346- if (event.ctrlKey) {
347- parts.push("ctrl");
348- }
349-
350- if (event.altKey) parts.push("alt");
351- if (event.shiftKey) parts.push("shift");
352-
353- // Add key
354- if (event.key) {
355- parts.push(event.key.toLowerCase());
356- }
357-
358- return parts.length > 0 ? parts.join("+") : null;
359- }
360-
361- private emit(type: KeyboardEventType, detail: KeyboardEventDetail): void {
362- const listeners = this.eventListeners.get(type);
363- if (listeners) {
364- const keyboardEvent: KeyboardEvent = { type, detail };
365- listeners.forEach((listener) => {
366- try {
367- listener(keyboardEvent);
368- } catch (error) {
369- this.log("error", "Event listener error", { type, error });
370- }
371- });
372- }
373- }
374-
375- private log(
376- level: "info" | "warn" | "error" | string,
377- message?: string,
378- data?: unknown,
379- ): void {
380- if (!this.debug && level !== "error") return;
381-
382- const logMessage = message
383- ? `[KeyboardService] ${message}`
384- : `[KeyboardService] ${level}`;
385-
386- switch (level) {
387- case "error":
388- console.error(logMessage, data);
389- break;
390- case "warn":
391- console.warn(logMessage, data);
392- break;
393- default:
394- console.log(logMessage, data);
395- }
396- }
397-}
398-
399-// Create and export singleton instance with global shortcuts
400-export const keyboardService = new KeyboardService({
401- debug: false, // Disable debug logging in production
402- globalShortcuts: [
403- // Navigation shortcuts (migrated from controls module)
404- {
405- key: "ctrl+arrowleft",
406- handler: () => {
407- if (window.history.length > 1) {
408- window.history.back();
409- }
410- },
411- description: "Navigate back (Ctrl+Left Arrow)",
412- },
413- {
414- key: "ctrl+arrowright",
415- handler: () => {
416- window.history.forward();
417- },
418- description: "Navigate forward (Ctrl+Right Arrow)",
419- },
420- // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled
421- // directly by the native Go shortcut handlers to ensure proper active pane detection.
422- // This avoids conflicts between multiple webview instances.
423- ],
424-});
425-
426-// Export types for use in components
427-export type {
428- ShortcutConfig,
429- ComponentShortcuts,
430- KeyboardServiceConfig,
431-} from "./types";
432-// COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers
D webui/src/lib/keyboard/index.ts +0 -17
@@ -1,17 +0,0 @@
1-/**
2- * Keyboard Service Module
3- *
4- * Centralized keyboard shortcut management for Dumber Browser.
5- * Provides type-safe keyboard event handling and component integration.
6- */
7-
8-export { KeyboardService, keyboardService } from "./KeyboardService";
9-export type {
10- ShortcutConfig,
11- ComponentShortcuts,
12- KeyboardServiceConfig,
13- KeyboardEventDetail,
14- KeyboardEvent,
15- KeyboardEventType,
16-} from "./types";
17-export { MODIFIER_KEYS, COMMON_SHORTCUTS } from "./types";
D webui/src/lib/keyboard/types.ts +0 -99
@@ -1,99 +0,0 @@
1-/**
2- * Keyboard Service Types
3- *
4- * Type definitions for the unified keyboard shortcut system.
5- * Provides type-safe interfaces for registering and handling keyboard shortcuts.
6- */
7-
8-export interface ShortcutConfig {
9- /** The keyboard shortcut key combination (e.g., 'ctrl+l', 'escape', 'enter') */
10- key: string;
11-
12- /** Function to execute when the shortcut is triggered */
13- handler: () => void;
14-
15- /** Whether to prevent the default browser behavior (default: true) */
16- preventDefault?: boolean;
17-
18- /** Whether to stop event propagation (default: true) */
19- stopPropagation?: boolean;
20-
21- /** Only trigger when this component is focused (default: false) */
22- whenFocused?: boolean;
23-
24- /** Optional description for debugging */
25- description?: string;
26-}
27-
28-export interface KeyboardEventDetail {
29- /** The original keyboard shortcut string */
30- shortcut: string;
31-
32- /** Timestamp when the event occurred */
33- timestamp: number;
34-
35- /** Component that should handle this event (if specified) */
36- targetComponent?: string;
37-}
38-
39-export interface ComponentShortcuts {
40- /** Unique component identifier */
41- componentId: string;
42-
43- /** Array of shortcuts registered by this component */
44- shortcuts: ShortcutConfig[];
45-
46- /** Whether this component is currently focused */
47- focused: boolean;
48-
49- /** Optional cleanup function called when component is destroyed */
50- cleanup?: () => void;
51-}
52-
53-export interface KeyboardServiceConfig {
54- /** Enable debug logging for keyboard events */
55- debug?: boolean;
56-
57- /** Global shortcuts that work regardless of component focus */
58- globalShortcuts?: ShortcutConfig[];
59-}
60-
61-/**
62- * Event types for keyboard service
63- */
64-export type KeyboardEventType =
65- | "shortcut-triggered"
66- | "component-focused"
67- | "component-blurred"
68- | "shortcut-registered"
69- | "shortcut-unregistered";
70-
71-export interface KeyboardEvent {
72- type: KeyboardEventType;
73- detail: KeyboardEventDetail | ComponentShortcuts | ShortcutConfig;
74-}
75-
76-/**
77- * Keyboard modifiers mapping
78- */
79-export const MODIFIER_KEYS = {
80- ctrl: "ctrlKey",
81- alt: "altKey",
82- shift: "shiftKey",
83-} as const;
84-
85-/**
86- * Common shortcut patterns used throughout the application
87- */
88-export const COMMON_SHORTCUTS = {
89- OMNIBOX_OPEN: "ctrl+l",
90- FIND_OPEN: "ctrl+f",
91- ESCAPE: "escape",
92- ENTER: "enter",
93- ARROW_UP: "arrowup",
94- ARROW_DOWN: "arrowdown",
95- ARROW_LEFT: "arrowleft",
96- ARROW_RIGHT: "arrowright",
97- TAB: "tab",
98- SHIFT_TAB: "shift+tab",
99-} as const;
M webui/src/pages/blocked.ts +0 -3
@@ -1,9 +1,6 @@
11 import { mount } from "svelte";
22 import "../styles/tailwind.css";
33 import BlockedPage from "./BlockedPage.svelte";
4-import { bootstrapGUI } from "../injected/bootstrap";
5-
6-bootstrapGUI();
74
85 // Mount the BlockedPage component to the DOM
96 console.log("[dumber] Mounting BlockedPage component to document.body");
M webui/src/pages/homepage.ts +0 -3
@@ -1,7 +1,6 @@
11 import { mount } from "svelte";
22 import "../styles/tailwind.css";
33 import Homepage from "./Homepage.svelte";
4-import { bootstrapGUI } from "../injected/bootstrap";
54
65 // Proactively unregister any Service Workers and clear caches that may have been
76 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to
@@ -26,8 +25,6 @@ async function cleanupServiceWorkersAndCaches() {
2625 }
2726 }
2827
29-bootstrapGUI();
30-
3128 // Mount the Homepage component to the DOM
3229 cleanupServiceWorkersAndCaches().finally(() => {
3330 console.log("[dumber] Mounting Homepage component to document.body");
D webui/src/services/app.ts +0 -141
@@ -1,141 +0,0 @@
1-import type { HistoryEntry, SearchShortcut } from "../types/generated.js";
2-
3-export class AppService {
4- private history: HistoryEntry[] = [];
5- private shortcuts: Record<string, SearchShortcut> = {};
6-
7- constructor() {}
8-
9- async initialize(): Promise<void> {
10- try {
11- await Promise.all([this.loadHistory(), this.loadShortcuts()]);
12- } catch (error) {
13- console.error("Failed to initialize app:", error);
14- }
15- }
16-
17- async loadHistory(): Promise<void> {
18- try {
19- // Use message bridge instead of fetch
20- return new Promise((resolve, reject) => {
21- // Set up response handler
22- window.__dumber_history_recent = (data: HistoryEntry[]) => {
23- this.history = Array.isArray(data) ? data : [];
24- resolve();
25- };
26-
27- window.__dumber_history_error = (error: string) => {
28- console.error("Failed to load history:", error);
29- reject(new Error(error));
30- };
31-
32- // Send message to Go backend
33- const bridge = window.webkit?.messageHandlers?.dumber;
34- if (bridge && typeof bridge.postMessage === "function") {
35- bridge.postMessage(
36- JSON.stringify({
37- type: "history_recent",
38- limit: 50,
39- offset: 0,
40- }),
41- );
42- } else {
43- reject(new Error("WebKit message handler not available"));
44- }
45- });
46- } catch (error) {
47- console.error("Failed to load history:", error);
48- // Mock history for development/fallback
49- this.history = [
50- {
51- id: 1,
52- url: "https://github.com/wailsapp/wails",
53- title: "Wails Framework",
54- visit_count: 3,
55- last_visited: new Date().toISOString(),
56- created_at: new Date().toISOString(),
57- favicon_url: null,
58- },
59- {
60- id: 2,
61- url: "https://go.dev",
62- title: "The Go Programming Language",
63- visit_count: 2,
64- last_visited: new Date().toISOString(),
65- created_at: new Date().toISOString(),
66- favicon_url: null,
67- },
68- {
69- id: 3,
70- url: "https://developer.mozilla.org",
71- title: "MDN Web Docs",
72- visit_count: 1,
73- last_visited: new Date().toISOString(),
74- created_at: new Date().toISOString(),
75- favicon_url: null,
76- },
77- ];
78- }
79- }
80-
81- async loadShortcuts(): Promise<void> {
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();
105- };
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- type: "get_search_shortcuts",
117- webview_id: (window as any).__dumber_webview_id ?? 0,
118- });
119- } else {
120- reject(new Error("WebKit message handler not available"));
121- }
122- });
123- }
124-
125- getHistory(): HistoryEntry[] {
126- return this.history;
127- }
128-
129- getShortcuts(): Record<string, SearchShortcut> {
130- return this.shortcuts;
131- }
132-
133- async copyToClipboard(text: string): Promise<void> {
134- try {
135- await navigator.clipboard.writeText(text);
136- console.log("Copied to clipboard:", text);
137- } catch (err) {
138- console.error("Failed to copy:", err);
139- }
140- }
141-}
D webui/src/services/browser-controls.ts +0 -245
@@ -1,245 +0,0 @@
1-import { NotificationService } from "./notifications.js";
2-import { DebugConsoleService } from "./debug-console.js";
3-
4-export class BrowserControlsService {
5- private currentURL = "";
6- private notifications = new NotificationService();
7- private debugConsole = new DebugConsoleService();
8-
9- constructor() {
10- // Debug logging with enhanced details
11- this.debugConsole.addLog(
12- "info",
13- "frontend",
14- "init",
15- "BrowserControlsService initializing...",
16- );
17- this.debugConsole.addLog(
18- "info",
19- "frontend",
20- "init",
21- "Running without Wails bindings",
22- );
23-
24- this.initializeEventListeners();
25- this.debugConsole.addLog(
26- "info",
27- "frontend",
28- "init",
29- "BrowserControlsService initialized",
30- );
31- }
32-
33- private initializeEventListeners(): void {
34- // Keyboard event handler for browser controls
35- document.addEventListener("keydown", this.handleKeyboardEvent.bind(this));
36-
37- // Mouse button handler for navigation
38- document.addEventListener("mousedown", this.handleMouseEvent.bind(this));
39-
40- // URL change listener (for SPA navigation)
41- window.addEventListener("popstate", this.handleURLChange.bind(this));
42- }
43-
44- private async handleKeyboardEvent(event: KeyboardEvent): Promise<void> {
45- // Skip F12 (debug console toggle)
46- if (event.key === "F12") {
47- return;
48- }
49-
50- // Log all keyboard events to debug console
51- this.debugConsole.logKeyboardEvent(event);
52-
53- const { ctrlKey, metaKey, shiftKey, altKey, key } = event;
54- const isCmd = ctrlKey || metaKey;
55-
56- // Zoom controls handled natively by GTK; don't intercept
57- if (isCmd && (key === "+" || key === "=" || key === "-" || key === "0")) {
58- return; // allow native handler to process
59- }
60-
61- // Copy URL
62- if (isCmd && shiftKey && key === "C") {
63- this.debugConsole.addLog(
64- "info",
65- "frontend",
66- "action",
67- "Copy URL triggered",
68- );
69- event.preventDefault();
70- await this.copyCurrentURL();
71- return;
72- }
73-
74- // Navigation
75- if (altKey && key === "ArrowLeft") {
76- this.debugConsole.addLog(
77- "info",
78- "frontend",
79- "action",
80- "Go Back triggered",
81- );
82- event.preventDefault();
83- history.back();
84- return;
85- }
86-
87- if (altKey && key === "ArrowRight") {
88- this.debugConsole.addLog(
89- "info",
90- "frontend",
91- "action",
92- "Go Forward triggered",
93- );
94- event.preventDefault();
95- history.forward();
96- return;
97- }
98- }
99-
100- private async handleMouseEvent(event: MouseEvent): Promise<void> {
101- // Log all mouse events to debug console
102- this.debugConsole.logMouseEvent(event);
103-
104- // Mouse button 3 (back button)
105- if (event.button === 3) {
106- this.debugConsole.addLog(
107- "info",
108- "frontend",
109- "action",
110- "Mouse Back button triggered",
111- );
112- event.preventDefault();
113- await this.goBack();
114- return;
115- }
116-
117- // Mouse button 4 (forward button)
118- if (event.button === 4) {
119- this.debugConsole.addLog(
120- "info",
121- "frontend",
122- "action",
123- "Mouse Forward button triggered",
124- );
125- event.preventDefault();
126- await this.goForward();
127- return;
128- }
129- }
130-
131- private handleURLChange(): void {
132- this.setCurrentURL(window.location.href);
133- this.loadSavedZoom();
134- }
135-
136- // Zoom is handled natively; no JS-based zoom adjustments needed.
137-
138- async copyCurrentURL(): Promise<void> {
139- try {
140- const url = this.getCurrentURL();
141- await navigator.clipboard.writeText(url);
142-
143- // URL copied successfully - no backend notification needed
144-
145- this.notifications.show("URL copied to clipboard!", "success");
146- } catch (error) {
147- console.error("Failed to copy URL:", error);
148- this.notifications.show("Failed to copy URL", "error");
149- }
150- }
151-
152- async goBack(): Promise<void> {
153- this.debugConsole.addLog("info", "frontend", "service", "Calling GoBack");
154-
155- try {
156- // await BrowserService.GoBack(); // TODO: Implement BrowserService
157- this.debugConsole.logServiceCall("BrowserService", "GoBack", []);
158- history.back();
159- this.debugConsole.addLog(
160- "info",
161- "frontend",
162- "navigation",
163- "Browser navigated back",
164- );
165- } catch (error) {
166- this.debugConsole.logServiceCall(
167- "BrowserService",
168- "GoBack",
169- [],
170- null,
171- error as Error,
172- );
173- console.error("Failed to go back:", error);
174- }
175- }
176-
177- async goForward(): Promise<void> {
178- this.debugConsole.addLog(
179- "info",
180- "frontend",
181- "service",
182- "Calling GoForward",
183- );
184-
185- try {
186- // await BrowserService.GoForward(); // TODO: Implement BrowserService
187- this.debugConsole.logServiceCall("BrowserService", "GoForward", []);
188- history.forward();
189- this.debugConsole.addLog(
190- "info",
191- "frontend",
192- "navigation",
193- "Browser navigated forward",
194- );
195- } catch (error) {
196- this.debugConsole.logServiceCall(
197- "BrowserService",
198- "GoForward",
199- [],
200- null,
201- error as Error,
202- );
203- console.error("Failed to go forward:", error);
204- }
205- }
206-
207- getCurrentURL(): string {
208- return this.currentURL || window.location.href;
209- }
210-
211- setCurrentURL(url: string): void {
212- this.currentURL = url;
213- this.updateWindowTitle(url);
214- }
215-
216- private async updateWindowTitle(url: string): Promise<void> {
217- try {
218- // Extract domain or path for title
219- let title = "Dumber Browser";
220- if (url && url !== "/") {
221- const urlObj = new URL(url);
222- title = `Dumber - ${urlObj.hostname}`;
223- }
224-
225- document.title = title;
226-
227- // Backend title updates handled natively; no frontend call
228- } catch (error) {
229- console.error("Failed to update title:", error);
230- }
231- }
232-
233- async loadSavedZoom(): Promise<void> {
234- // Zoom persistence applied natively on page load
235- }
236-
237- async initialize(): Promise<void> {
238- // Check if there's an initial URL from backend (direct navigation)
239- // Set initial URL from browser location (homepage)
240- this.setCurrentURL(window.location.href);
241-
242- // Load saved zoom level (only if we're staying on homepage)
243- // Zoom handled natively
244- }
245-}
D webui/src/services/debug-console.ts +0 -295
@@ -1,295 +0,0 @@
1-export interface DebugLogEntry {
2- timestamp: string;
3- level: "info" | "warn" | "error" | "debug";
4- source: "frontend" | "backend";
5- category: string;
6- message: string;
7- data?: unknown;
8-}
9-
10-export class DebugConsoleService {
11- private logs: DebugLogEntry[] = [];
12- private maxLogs = 1000;
13- private isVisible = false;
14- private debugElement: HTMLElement | null = null;
15- private logsContainer: HTMLElement | null = null;
16-
17- constructor() {
18- this.createDebugUI();
19- this.setupKeyboardShortcut();
20- this.interceptConsole();
21- }
22-
23- private createDebugUI(): void {
24- // Create debug panel
25- this.debugElement = document.createElement("div");
26- this.debugElement.id = "debug-console";
27- this.debugElement.style.cssText = `
28- position: fixed;
29- top: 0;
30- right: 0;
31- width: 50%;
32- height: 100%;
33- background: rgba(0, 0, 0, 0.95);
34- color: #00ff00;
35- font-family: 'Courier New', monospace;
36- font-size: 12px;
37- z-index: 10000;
38- display: none;
39- border-left: 2px solid #00ff00;
40- `;
41-
42- // Create header
43- const header = document.createElement("div");
44- header.style.cssText = `
45- padding: 10px;
46- background: rgba(0, 255, 0, 0.1);
47- border-bottom: 1px solid #00ff00;
48- font-weight: bold;
49- display: flex;
50- justify-content: space-between;
51- align-items: center;
52- `;
53- header.innerHTML = `
54- <span>🐛 DUMBER DEBUG CONSOLE</span>
55- <div style="font-size: 10px;">
56- <span style="color: #888; margin-right: 10px;">F11: Inspect Services | F12: Toggle</span>
57- <button id="clear-logs" style="background: none; border: 1px solid #00ff00; color: #00ff00; padding: 2px 8px; margin-right: 5px; cursor: pointer;">Clear</button>
58- <button id="close-debug" style="background: none; border: 1px solid #00ff00; color: #00ff00; padding: 2px 8px; cursor: pointer;">Close</button>
59- </div>
60- `;
61-
62- // Create logs container
63- this.logsContainer = document.createElement("div");
64- this.logsContainer.style.cssText = `
65- height: calc(100% - 50px);
66- overflow-y: auto;
67- padding: 10px;
68- `;
69-
70- this.debugElement.appendChild(header);
71- this.debugElement.appendChild(this.logsContainer);
72- document.body.appendChild(this.debugElement);
73-
74- // Setup button handlers
75- header
76- .querySelector("#clear-logs")
77- ?.addEventListener("click", () => this.clearLogs());
78- header
79- .querySelector("#close-debug")
80- ?.addEventListener("click", () => this.hide());
81- }
82-
83- private setupKeyboardShortcut(): void {
84- document.addEventListener("keydown", (event) => {
85- // F12 to toggle debug console
86- if (event.key === "F12") {
87- event.preventDefault();
88- this.toggle();
89- }
90- });
91- }
92-
93- private interceptConsole(): void {
94- const originalConsole = {
95- log: console.log,
96- warn: console.warn,
97- error: console.error,
98- info: console.info,
99- debug: console.debug,
100- };
101-
102- console.log = (...args) => {
103- this.addLog("info", "frontend", "console", args.join(" "));
104- originalConsole.log(...args);
105- };
106-
107- console.warn = (...args) => {
108- this.addLog("warn", "frontend", "console", args.join(" "));
109- originalConsole.warn(...args);
110- };
111-
112- console.error = (...args) => {
113- this.addLog("error", "frontend", "console", args.join(" "));
114- originalConsole.error(...args);
115- };
116-
117- console.info = (...args) => {
118- this.addLog("info", "frontend", "console", args.join(" "));
119- originalConsole.info(...args);
120- };
121-
122- console.debug = (...args) => {
123- this.addLog("debug", "frontend", "console", args.join(" "));
124- originalConsole.debug(...args);
125- };
126- }
127-
128- public addLog(
129- level: DebugLogEntry["level"],
130- source: DebugLogEntry["source"],
131- category: string,
132- message: string,
133- data?: unknown,
134- ): void {
135- const entry: DebugLogEntry = {
136- timestamp: new Date().toISOString().split("T")[1]?.split(".")[0] || "", // HH:MM:SS format
137- level,
138- source,
139- category,
140- message,
141- data,
142- };
143-
144- this.logs.push(entry);
145-
146- // Keep only the last maxLogs entries
147- if (this.logs.length > this.maxLogs) {
148- this.logs.shift();
149- }
150-
151- this.updateDisplay();
152- }
153-
154- private updateDisplay(): void {
155- if (!this.logsContainer) return;
156-
157- const logHTML = this.logs
158- .map((log) => {
159- const levelColor = {
160- info: "#00ff00",
161- warn: "#ffff00",
162- error: "#ff0000",
163- debug: "#00ffff",
164- }[log.level];
165-
166- const sourceIcon = log.source === "backend" ? "🖥️" : "🌐";
167-
168- return `
169- <div style="margin-bottom: 2px; font-size: 11px;">
170- <span style="color: #888;">[${log.timestamp}]</span>
171- <span style="color: ${levelColor};">${sourceIcon} ${log.level.toUpperCase()}</span>
172- <span style="color: #aaa;">[${log.category}]</span>
173- <span style="color: #fff;">${log.message}</span>
174- ${log.data ? `<pre style="color: #ccc; font-size: 10px; margin: 2px 0;">${JSON.stringify(log.data, null, 2)}</pre>` : ""}
175- </div>
176- `;
177- })
178- .join("");
179-
180- this.logsContainer.innerHTML = logHTML;
181-
182- // Auto-scroll to bottom
183- this.logsContainer.scrollTop = this.logsContainer.scrollHeight;
184- }
185-
186- public logKeyboardEvent(event: KeyboardEvent): void {
187- const modifiers = [];
188- if (event.ctrlKey) modifiers.push("Ctrl");
189- if (event.shiftKey) modifiers.push("Shift");
190- if (event.altKey) modifiers.push("Alt");
191- if (event.metaKey) modifiers.push("Meta");
192-
193- const shortcut =
194- modifiers.length > 0 ? `${modifiers.join("+")}+${event.key}` : event.key;
195-
196- this.addLog("info", "frontend", "keyboard", `Key pressed: ${shortcut}`, {
197- key: event.key,
198- code: event.code,
199- modifiers,
200- target: event.target?.constructor.name,
201- });
202- }
203-
204- public logMouseEvent(event: MouseEvent): void {
205- const buttonNames: Record<number, string> = {
206- 0: "Left",
207- 1: "Middle",
208- 2: "Right",
209- 3: "Back",
210- 4: "Forward",
211- };
212-
213- this.addLog(
214- "info",
215- "frontend",
216- "mouse",
217- `Mouse ${event.type}: ${buttonNames[event.button] || `Button${event.button}`}`,
218- {
219- button: event.button,
220- clientX: event.clientX,
221- clientY: event.clientY,
222- target: event.target?.constructor.name,
223- },
224- );
225- }
226-
227- public logServiceCall(
228- serviceName: string,
229- methodName: string,
230- params: unknown[],
231- result?: unknown,
232- error?: Error,
233- ): void {
234- if (error) {
235- this.addLog(
236- "error",
237- "frontend",
238- "service",
239- `${serviceName}.${methodName}() failed`,
240- {
241- params,
242- error: error.message,
243- },
244- );
245- } else {
246- this.addLog(
247- "info",
248- "frontend",
249- "service",
250- `${serviceName}.${methodName}() called`,
251- {
252- params,
253- result,
254- },
255- );
256- }
257- }
258-
259- public logBackendMessage(message: string, data?: unknown): void {
260- this.addLog("info", "backend", "service", message, data);
261- }
262-
263- public show(): void {
264- if (this.debugElement) {
265- this.debugElement.style.display = "block";
266- this.isVisible = true;
267- this.addLog("info", "frontend", "debug", "Debug console opened");
268- }
269- }
270-
271- public hide(): void {
272- if (this.debugElement) {
273- this.debugElement.style.display = "none";
274- this.isVisible = false;
275- }
276- }
277-
278- public toggle(): void {
279- if (this.isVisible) {
280- this.hide();
281- } else {
282- this.show();
283- }
284- }
285-
286- public clearLogs(): void {
287- this.logs = [];
288- this.updateDisplay();
289- this.addLog("info", "frontend", "debug", "Logs cleared");
290- }
291-
292- public isDebugVisible(): boolean {
293- return this.isVisible;
294- }
295-}
D webui/src/services/notifications.ts +0 -115
@@ -1,115 +0,0 @@
1-import type { NotificationOptions } from "../types/app.js";
2-
3-interface TimerElement extends HTMLElement {
4- timeout?: ReturnType<typeof setTimeout>;
5-}
6-
7-export class NotificationService {
8- private zoomNotification: HTMLElement | null = null;
9- private generalNotification: HTMLElement | null = null;
10-
11- showZoom(message: string, duration = 2000): void {
12- // Create or update zoom notification
13- if (!this.zoomNotification) {
14- this.zoomNotification = this.createNotificationElement(
15- "zoom-notification",
16- {
17- top: "20px",
18- right: "20px",
19- background: "rgba(0, 0, 0, 0.8)",
20- color: "white",
21- },
22- );
23- }
24-
25- this.updateNotification(this.zoomNotification, message, duration);
26- }
27-
28- show(
29- message: string,
30- type: NotificationOptions["type"] = "info",
31- duration = 3000,
32- ): void {
33- // Create or update general notification
34- if (!this.generalNotification) {
35- this.generalNotification = this.createNotificationElement(
36- "general-notification",
37- {
38- top: "60px",
39- right: "20px",
40- },
41- );
42- }
43-
44- // Set colors based on type
45- const colors = this.getNotificationColors(type);
46- this.generalNotification.style.background = colors.background;
47- this.generalNotification.style.color = colors.color;
48-
49- this.updateNotification(this.generalNotification, message, duration);
50- }
51-
52- private createNotificationElement(
53- id: string,
54- baseStyles: Record<string, string>,
55- ): HTMLElement {
56- const notification = document.createElement("div");
57- notification.id = id;
58-
59- const styles = {
60- position: "fixed",
61- padding: "10px 15px",
62- borderRadius: "5px",
63- fontSize: "14px",
64- zIndex: "10000",
65- transition: "opacity 0.3s",
66- opacity: "0",
67- ...baseStyles,
68- };
69-
70- notification.style.cssText = Object.entries(styles)
71- .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
72- .join("; ");
73-
74- document.body.appendChild(notification);
75- return notification;
76- }
77-
78- private updateNotification(
79- element: HTMLElement,
80- message: string,
81- duration: number,
82- ): void {
83- element.textContent = message;
84- element.style.opacity = "1";
85-
86- // Clear any existing timeout
87- const timerElement = element as TimerElement;
88- if (timerElement.timeout) {
89- clearTimeout(timerElement.timeout);
90- }
91-
92- // Hide after duration
93- timerElement.timeout = setTimeout(() => {
94- element.style.opacity = "0";
95- }, duration);
96- }
97-
98- private getNotificationColors(type: NotificationOptions["type"]): {
99- background: string;
100- color: string;
101- } {
102- switch (type) {
103- case "error":
104- return { background: "rgba(220, 53, 69, 0.9)", color: "white" };
105- case "success":
106- return { background: "rgba(25, 135, 84, 0.9)", color: "white" };
107- default:
108- return { background: "rgba(13, 110, 253, 0.9)", color: "white" };
109- }
110- }
111-
112- private camelToKebab(str: string): string {
113- return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
114- }
115-}
D webui/src/types/app.ts +0 -26
@@ -1,26 +0,0 @@
1-import type { HistoryEntry, SearchShortcut } from "./generated.js";
2-
3-export interface AppState {
4- currentURL: string;
5- currentZoom: number;
6- history: HistoryEntry[];
7- shortcuts: Record<string, SearchShortcut>;
8- isLoading: boolean;
9-}
10-
11-export interface NotificationOptions {
12- message: string;
13- type?: "info" | "success" | "error";
14- duration?: number;
15-}
16-
17-export interface KeyboardShortcut {
18- key: string;
19- ctrlKey?: boolean;
20- metaKey?: boolean;
21- shiftKey?: boolean;
22- altKey?: boolean;
23- handler: () => void | Promise<void>;
24-}
25-
26-export { HistoryEntry, SearchShortcut } from "./generated.js";
D webui/src/types/generated.ts +0 -89
@@ -1,89 +0,0 @@
1-// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
2-// Auto-generated TypeScript types from Go structs
3-// Generated on: 2025-09-22T12:41:57.478Z
4-// Generator script: scripts/generate-types.js
5-// Source files: internal/db/models.go, internal/parser/types.go
6-// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
7-
8-export interface CertificateValidation {
9- id: number;
10- hostname: string;
11- certificate_hash: string;
12- user_decision: string;
13- created_at: string | null;
14- expires_at: string | null;
15-}
16-
17-export interface History {
18- id: number;
19- url: string;
20- title: string | null;
21- visit_count: number | null;
22- last_visited: string | null;
23- created_at: string | null;
24- favicon_url: string | null;
25-}
26-
27-export interface Shortcut {
28- id: number;
29- shortcut: string;
30- url_template: string;
31- description: string | null;
32- created_at: string | null;
33-}
34-
35-export interface ZoomLevel {
36- domain: string;
37- zoom_factor: number;
38- updated_at: string | null;
39-}
40-
41-export interface ParseResult {
42- type: InputType;
43- url: string;
44- query: string;
45- confidence: number;
46- fuzzy_matches?: FuzzyMatch[];
47- shortcut?: DetectedShortcut;
48- processing_time: number;
49-}
50-
51-export interface DetectedShortcut {
52- key: string;
53- query: string;
54- url: string;
55- description: string;
56-}
57-
58-export interface FuzzyMatch {
59- history_entry?: History;
60- score: number;
61- url_score: number;
62- title_score: number;
63- recency_score: number;
64- visit_score: number;
65- matched_field: string;
66-}
67-
68-export interface FuzzyConfig {
69- min_similarity_threshold: number;
70- max_results: number;
71- url_weight: number;
72- title_weight: number;
73- recency_weight: number;
74- visit_weight: number;
75- recency_decay_days: number;
76-}
77-
78-// Manual types and enums
79-export enum InputType {
80- InputTypeDirectURL = 0,
81- InputTypeSearchShortcut = 1,
82- InputTypeHistorySearch = 2,
83- InputTypeFallbackSearch = 3,
84-}
85-
86-// Type aliases for frontend usage
87-export type HistoryEntry = History;
88-export type SearchShortcut = Shortcut;
89-export type ZoomEntry = ZoomLevel;
D webui/src/types/inline-css.d.ts +0 -4
@@ -1,4 +0,0 @@
1-declare module "*.css?inline" {
2- const content: string;
3- export default content;
4-}
D webui/src/types/raw-css.d.ts +0 -4
@@ -1,4 +0,0 @@
1-declare module "*.css?raw" {
2- const content: string;
3- export default content;
4-}
D webui/vite.config.blocked.ts +0 -46
@@ -1,46 +0,0 @@
1-import { defineConfig } from "vite";
2-import { svelte } from "@sveltejs/vite-plugin-svelte";
3-import { resolve } from "path";
4-import { pageGenerator } from "./vite-plugin-pages";
5-
6-export default defineConfig({
7- plugins: [
8- svelte({
9- emitCss: false, // Inline component CSS for shadow DOM compatibility
10- }),
11- pageGenerator([
12- {
13- name: "blocked",
14- title: "Page Blocked",
15- script: "blocked.min.js",
16- filename: "blocked.html",
17- },
18- ]),
19- ],
20- build: {
21- rollupOptions: {
22- input: resolve(__dirname, "src/pages/blocked.ts"),
23- output: {
24- dir: "../assets/webui",
25- entryFileNames: "blocked.min.js",
26- chunkFileNames: "[name].js",
27- assetFileNames: "[name].[ext]",
28- manualChunks: undefined,
29- format: "iife",
30- name: "DumberBlocked",
31- },
32- },
33- emptyOutDir: false,
34- target: ["es2020", "chrome91", "firefox90"],
35- minify: true,
36- sourcemap: false,
37- cssCodeSplit: false,
38- assetsInlineLimit: 4096,
39- },
40- resolve: {
41- alias: {
42- $lib: resolve(__dirname, "src/lib"),
43- $components: resolve(__dirname, "src/components"),
44- },
45- },
46-});
D webui/vite.config.color-scheme.ts +0 -28
@@ -1,28 +0,0 @@
1-import { defineConfig } from "vite";
2-import { resolve } from "path";
3-
4-export default defineConfig({
5- build: {
6- rollupOptions: {
7- input: resolve(__dirname, "src/injected/modules/color-scheme.ts"),
8- output: {
9- dir: "../assets/webui",
10- format: "iife",
11- entryFileNames: "color-scheme.js",
12- inlineDynamicImports: true,
13- manualChunks: undefined, // Prevent chunk splitting
14- },
15- },
16- emptyOutDir: false, // Don't clear the directory since GUI bundle is also built here
17- target: ["es2020", "chrome91", "firefox90"],
18- minify: true,
19- sourcemap: false,
20- },
21- resolve: {
22- alias: {
23- // Alias for easier imports
24- $lib: resolve(__dirname, "src/lib"),
25- $components: resolve(__dirname, "src/components"),
26- },
27- },
28-});
D webui/vite.config.gui.ts +0 -38
@@ -1,38 +0,0 @@
1-import { defineConfig } from "vite";
2-import { svelte } from "@sveltejs/vite-plugin-svelte";
3-import { resolve } from "path";
4-
5-export default defineConfig({
6- plugins: [
7- svelte({
8- emitCss: true, // Extract component CSS to separate file for isolated world injection
9- }),
10- ],
11- build: {
12- rollupOptions: {
13- input: resolve(__dirname, "src/injected/gui.ts"),
14- output: {
15- dir: "../assets/webui",
16- format: "iife",
17- entryFileNames: "gui.min.js",
18- name: "__dumberGUI",
19- inlineDynamicImports: true,
20- manualChunks: undefined, // Prevent chunk splitting
21- assetFileNames: "gui.min.[ext]", // CSS will be named gui.min.css
22- },
23- },
24- emptyOutDir: false,
25- target: ["es2020", "chrome91", "firefox90"],
26- minify: true,
27- sourcemap: false,
28- cssCodeSplit: false, // Prevent CSS code splitting
29- assetsInlineLimit: 100000000, // Inline all assets as data URLs (100MB limit)
30- },
31- resolve: {
32- alias: {
33- // Alias for easier imports
34- $lib: resolve(__dirname, "src/lib"),
35- $components: resolve(__dirname, "src/components"),
36- },
37- },
38-});
D webui/vite.config.homepage.ts +0 -46
@@ -1,46 +0,0 @@
1-import { defineConfig } from "vite";
2-import { svelte } from "@sveltejs/vite-plugin-svelte";
3-import { resolve } from "path";
4-import { pageGenerator } from "./vite-plugin-pages";
5-
6-export default defineConfig({
7- plugins: [
8- svelte({
9- emitCss: false, // Inline component CSS for shadow DOM compatibility
10- }),
11- pageGenerator([
12- {
13- name: "homepage",
14- title: "Dumber Browser",
15- script: "homepage.min.js",
16- filename: "index.html",
17- },
18- ]),
19- ],
20- build: {
21- rollupOptions: {
22- input: resolve(__dirname, "src/pages/homepage.ts"),
23- output: {
24- dir: "../assets/webui",
25- entryFileNames: "homepage.min.js",
26- chunkFileNames: "[name].js",
27- assetFileNames: "[name].[ext]",
28- manualChunks: undefined,
29- format: "iife",
30- name: "DumberHomepage",
31- },
32- },
33- emptyOutDir: false,
34- target: ["es2020", "chrome91", "firefox90"],
35- minify: true,
36- sourcemap: false,
37- cssCodeSplit: false,
38- assetsInlineLimit: 4096,
39- },
40- resolve: {
41- alias: {
42- $lib: resolve(__dirname, "src/lib"),
43- $components: resolve(__dirname, "src/components"),
44- },
45- },
46-});
D webui/vite.config.main-world.ts +0 -28
@@ -1,28 +0,0 @@
1-import { defineConfig } from "vite";
2-import { resolve } from "path";
3-
4-export default defineConfig({
5- build: {
6- rollupOptions: {
7- input: resolve(__dirname, "src/injected/main-world.ts"),
8- output: {
9- dir: "../assets/webui",
10- format: "iife",
11- entryFileNames: "main-world.min.js",
12- name: "__dumberMainWorld",
13- inlineDynamicImports: true,
14- manualChunks: undefined,
15- assetFileNames: "[name].[ext]",
16- },
17- },
18- emptyOutDir: false,
19- target: ["es2020", "chrome91", "firefox90"],
20- minify: true,
21- sourcemap: false,
22- cssCodeSplit: false,
23- assetsInlineLimit: 100000000,
24- },
25- define: {
26- __DOM_ZOOM_DEFAULT__: "1.0", // Default placeholder, will be replaced by Go
27- },
28-});
A webui/vite.config.ts +76 -0
@@ -0,0 +1,76 @@
1+import { defineConfig } from "vite";
2+import { svelte } from "@sveltejs/vite-plugin-svelte";
3+import { resolve } from "path";
4+import { pageGenerator } from "./vite-plugin-pages";
5+
6+// Page configurations for dumb:// protocol pages
7+const pages = {
8+ homepage: {
9+ entry: "src/pages/homepage.ts",
10+ output: "homepage.min.js",
11+ global: "DumberHomepage",
12+ html: {
13+ name: "homepage",
14+ title: "Dumber Browser",
15+ script: "homepage.min.js",
16+ filename: "index.html",
17+ },
18+ },
19+ blocked: {
20+ entry: "src/pages/blocked.ts",
21+ output: "blocked.min.js",
22+ global: "DumberBlocked",
23+ html: {
24+ name: "blocked",
25+ title: "Page Blocked",
26+ script: "blocked.min.js",
27+ filename: "blocked.html",
28+ },
29+ },
30+} as const;
31+
32+// Get page from VITE_PAGE env var, default to building all
33+const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined;
34+
35+// Build single page config
36+function buildPageConfig(pageName: keyof typeof pages) {
37+ const page = pages[pageName];
38+ return defineConfig({
39+ plugins: [
40+ svelte({
41+ emitCss: false, // Inline component CSS for shadow DOM compatibility
42+ }),
43+ pageGenerator([page.html]),
44+ ],
45+ build: {
46+ rollupOptions: {
47+ input: resolve(__dirname, page.entry),
48+ output: {
49+ dir: "../assets/webui",
50+ entryFileNames: page.output,
51+ chunkFileNames: "[name].js",
52+ assetFileNames: "[name].[ext]",
53+ manualChunks: undefined,
54+ format: "iife",
55+ name: page.global,
56+ },
57+ },
58+ emptyOutDir: false,
59+ target: ["es2020", "chrome91", "firefox90"],
60+ minify: true,
61+ sourcemap: false,
62+ cssCodeSplit: false,
63+ assetsInlineLimit: 4096,
64+ },
65+ resolve: {
66+ alias: {
67+ $lib: resolve(__dirname, "src/lib"),
68+ $components: resolve(__dirname, "src/components"),
69+ },
70+ },
71+ });
72+}
73+
74+// Export config for specified page or default to homepage
75+// Use VITE_PAGE=homepage or VITE_PAGE=blocked to select
76+export default buildPageConfig(targetPage || "homepage");

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