detached 22d75d9

feat(gui): add complete svelte 5 gui with tailwind css and vite build system

commit
22d75d9e604885a29b61e0aaa7c68392cffe836a
author
bamen · · 2025-09-15 05:21 UTC
parent
79f9593
signature
unsigned
45 files · +9603 -0
A gui/bindings/database/sql/index.ts +8 -0
@@ -0,0 +1,8 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+export {
5+ NullInt64,
6+ NullString,
7+ NullTime
8+} from "./models.js";
A gui/bindings/time/index.ts +10 -0
@@ -0,0 +1,10 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+export {
5+ Duration
6+} from "./models.js";
7+
8+export type {
9+ Time
10+} from "./models.js";
A gui/eslint.config.js +35 -0
@@ -0,0 +1,35 @@
1+import js from '@eslint/js';
2+import tseslint from 'typescript-eslint';
3+
4+export default tseslint.config(
5+ js.configs.recommended,
6+ ...tseslint.configs.recommended,
7+ {
8+ files: ['**/*.{js,mjs,cjs,ts}'],
9+ languageOptions: {
10+ ecmaVersion: 2024,
11+ sourceType: 'module',
12+ },
13+ rules: {
14+ // Modern TypeScript best practices
15+ '@typescript-eslint/no-unused-vars': ['error', {
16+ 'argsIgnorePattern': '^_',
17+ 'varsIgnorePattern': '^_'
18+ }],
19+ '@typescript-eslint/no-explicit-any': 'warn',
20+ '@typescript-eslint/no-empty-function': 'off',
21+ '@typescript-eslint/consistent-type-imports': 'error',
22+ '@typescript-eslint/no-import-type-side-effects': 'error',
23+
24+ // Modern JavaScript/TypeScript patterns
25+ 'prefer-const': 'error',
26+ 'no-var': 'error',
27+ 'object-shorthand': 'error',
28+ 'prefer-template': 'error',
29+ },
30+ },
31+ {
32+ files: ['**/*.js', '**/*.mjs'],
33+ ...tseslint.configs.disableTypeChecked,
34+ }
35+);⏎̸
A gui/package-lock.json +4576 -0 generated file: collapsed
A gui/package.json +33 -0
@@ -0,0 +1,33 @@
1+{
2+ "name": "@dumber/gui",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "build": "npm run build:gui && npm run build:homepage",
8+ "build:gui": "vite build --config vite.config.gui.ts",
9+ "build:homepage": "vite build --config vite.config.homepage.ts",
10+ "dev": "npm run build:gui -- --watch",
11+ "dev:homepage": "npm run build:homepage -- --watch",
12+ "check": "svelte-check",
13+ "lint": "eslint src --fix",
14+ "generate:types": "node ../scripts/generate-types.js"
15+ },
16+ "devDependencies": {
17+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
18+ "@tailwindcss/cli": "^4.0.0",
19+ "@tailwindcss/postcss": "^4.1.13",
20+ "@types/node": "^20.0.0",
21+ "@typescript-eslint/eslint-plugin": "^7.0.0",
22+ "@typescript-eslint/parser": "^7.0.0",
23+ "autoprefixer": "^10.4.20",
24+ "eslint": "^8.57.0",
25+ "postcss": "^8.4.47",
26+ "svelte": "^5.0.0",
27+ "svelte-check": "^4.0.0",
28+ "tailwindcss": "^4.0.0",
29+ "typescript": "^5.3.0",
30+ "typescript-eslint": "^8.43.0",
31+ "vite": "^5.0.0"
32+ }
33+}
A gui/postcss.config.js +6 -0
@@ -0,0 +1,6 @@
1+export default {
2+ plugins: {
3+ '@tailwindcss/postcss': {},
4+ autoprefixer: {},
5+ },
6+};⏎̸
A gui/src/build-html.ts +24 -0
@@ -0,0 +1,24 @@
1+import { writeFileSync } from 'fs';
2+import { join } from 'path';
3+import { HTMLBuilder } from './utils/html-builder.js';
4+
5+// Generate the HTML file
6+function buildHTML() {
7+ try {
8+ const html = HTMLBuilder.createBaseHTML();
9+ const outputPath = join(process.cwd(), 'dist', 'index.html');
10+ writeFileSync(outputPath, html);
11+ // Also emit favicon.svg as a file for dumb:// scheme lookups
12+ const favPath = join(process.cwd(), 'dist', 'favicon.svg');
13+ writeFileSync(favPath, HTMLBuilder.getFaviconSVG());
14+ console.log('✓ Generated index.html');
15+ } catch (error) {
16+ console.error('✗ Failed to generate HTML:', error);
17+ process.exit(1);
18+ }
19+}
20+
21+// Only run when called directly (not imported)
22+if (import.meta.url === `file://${process.argv[1]}`) {
23+ buildHTML();
24+}
A gui/src/components/omnibox/Omnibox.svelte +209 -0
@@ -0,0 +1,209 @@
1+<!--
2+ Omnibox Component
3+
4+ Main container for omnibox/find functionality using Svelte 5 runes
5+-->
6+<script lang="ts">
7+ import { onMount, onDestroy } from 'svelte';
8+ import OmniboxInput from './OmniboxInput.svelte';
9+ import OmniboxSuggestions from './OmniboxSuggestions.svelte';
10+ import OmniboxFind from './OmniboxFind.svelte';
11+ import { omniboxStore } from './stores.svelte.ts';
12+ import { omniboxBridge } from './messaging';
13+
14+ // Reactive state from store
15+ let visible = $derived(omniboxStore.visible);
16+ let mode = $derived(omniboxStore.mode);
17+ let faded = $derived(omniboxStore.faded);
18+
19+ // Component refs
20+ let boxElement: HTMLDivElement;
21+ let inputElement = $state<HTMLInputElement>();
22+
23+ // Handle click outside to close
24+ function handleOverlayClick(event: MouseEvent) {
25+ const target = event.target as HTMLElement;
26+ if (target && boxElement && !boxElement.contains(target)) {
27+ event.preventDefault();
28+ event.stopPropagation();
29+ omniboxStore.close();
30+ }
31+ }
32+
33+ // Handle box click to prevent propagation
34+ function handleBoxClick(event: MouseEvent) {
35+ event.stopPropagation();
36+ }
37+
38+ // Handle mouse enter to focus input and clear fade
39+ function handleMouseEnter() {
40+ if (visible && inputElement) {
41+ try {
42+ inputElement.focus({ preventScroll: true });
43+ } catch {
44+ inputElement.focus();
45+ }
46+ omniboxStore.setFaded(false);
47+ }
48+ }
49+
50+ // Focus input when component becomes visible
51+ $effect(() => {
52+ if (visible && inputElement) {
53+ inputElement.focus();
54+ }
55+ });
56+
57+
58+ // Apply faded styling effect (matching original JS implementation)
59+ $effect(() => {
60+ if (!boxElement || !inputElement) return;
61+
62+ if (faded) {
63+ // Faded state - semi-transparent with backdrop blur
64+ boxElement.style.background = 'rgba(27,27,27,0.25)';
65+ boxElement.style.backdropFilter = 'blur(2px) saturate(110%)';
66+ (boxElement.style as any).webkitBackdropFilter = 'blur(2px) saturate(110%)';
67+ inputElement.style.background = 'rgba(18,18,18,0.35)';
68+ inputElement.style.color = '#eee';
69+ } else {
70+ // Normal state - solid background
71+ boxElement.style.background = '#1b1b1b';
72+ boxElement.style.backdropFilter = '';
73+ (boxElement.style as any).webkitBackdropFilter = '';
74+ inputElement.style.background = '#121212';
75+ inputElement.style.color = '#eee';
76+ }
77+ });
78+
79+ // Global click handler to close omnibox when clicking anywhere outside
80+ function handleGlobalClick(event: MouseEvent) {
81+ if (!visible) return;
82+
83+ const target = event.target as HTMLElement;
84+ const omniboxContainer = document.getElementById('dumber-omnibox-root');
85+
86+ // Close if clicking outside the omnibox container
87+ if (omniboxContainer && !omniboxContainer.contains(target)) {
88+ omniboxStore.close();
89+ }
90+ }
91+
92+ onMount(() => {
93+ console.log('🔧 Omnibox component mounted');
94+
95+ // Add global click listener
96+ document.addEventListener('click', handleGlobalClick, true);
97+
98+ try {
99+ // Set up global API for Go bridge
100+ window.__dumber_omnibox = {
101+ setSuggestions: (suggestions) => {
102+ try {
103+ console.log('📋 setSuggestions called with:', suggestions?.length || 0, 'items');
104+ omniboxBridge.setSuggestions(suggestions);
105+ } catch (error) {
106+ console.error('Error in setSuggestions:', error);
107+ }
108+ },
109+ toggle: () => {
110+ try {
111+ console.log('🔄 Omnibox toggle called, current visible:', omniboxStore.visible);
112+ omniboxStore.toggle();
113+ console.log('🔄 After toggle, visible:', omniboxStore.visible);
114+ } catch (error) {
115+ console.error('Error in toggle:', error);
116+ }
117+ },
118+ open: (mode, query) => {
119+ try {
120+ console.log('🚪 Omnibox open called with mode:', mode, 'query:', query);
121+ omniboxStore.open(mode as any, query);
122+ console.log('🚪 After open, visible:', omniboxStore.visible, 'mode:', omniboxStore.mode);
123+ } catch (error) {
124+ console.error('Error in open:', error);
125+ }
126+ },
127+ close: () => {
128+ try {
129+ console.log('🚪 Omnibox close called');
130+ omniboxStore.close();
131+ } catch (error) {
132+ console.error('Error in close:', error);
133+ }
134+ },
135+ findQuery: (query) => {
136+ try {
137+ console.log('🔍 Find query called with:', query);
138+ if (omniboxStore.mode !== 'find') {
139+ omniboxStore.setMode('find');
140+ }
141+ if (!omniboxStore.visible) {
142+ omniboxStore.setVisible(true);
143+ }
144+ omniboxStore.setInputValue(query || '');
145+ // Find will be triggered by input value change
146+ } catch (error) {
147+ console.error('Error in findQuery:', error);
148+ }
149+ }
150+ };
151+
152+ console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
153+ } catch (error) {
154+ console.error('❌ Failed to set up omnibox global API:', error);
155+ }
156+ });
157+
158+ onDestroy(() => {
159+ // Cleanup global click listener
160+ document.removeEventListener('click', handleGlobalClick, true);
161+
162+ // Reset omnibox state completely
163+ omniboxStore.reset();
164+
165+ // Cleanup global API
166+ if (window.__dumber_omnibox) {
167+ delete window.__dumber_omnibox;
168+ }
169+ });
170+</script>
171+
172+<!-- Overlay container -->
173+<div
174+ class="fixed inset-0 z-[2147483647]"
175+ style="display: {visible ? 'block' : 'none'}"
176+ onmousedown={handleOverlayClick}
177+ role="presentation"
178+>
179+ <!-- Main omnibox container -->
180+ <div
181+ bind:this={boxElement}
182+ class="w-[min(90vw,720px)] mx-auto mt-[8vh] p-3
183+ bg-[#1b1b1b] border border-[#444] rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.6)]
184+ text-[#eee]
185+ sm:w-[95vw] sm:p-2
186+ xl:w-[min(90vw,840px)]"
187+ style="font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif"
188+ onmousedown={handleBoxClick}
189+ onmouseenter={handleMouseEnter}
190+ role="dialog"
191+ tabindex="-1"
192+ aria-modal="true"
193+ aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'}
194+ >
195+ <!-- Input component -->
196+ <OmniboxInput bind:inputElement />
197+
198+ <!-- Content based on mode -->
199+ {#if mode === 'omnibox'}
200+ <OmniboxSuggestions />
201+ {:else if mode === 'find'}
202+ <OmniboxFind />
203+ {/if}
204+ </div>
205+</div>
206+
207+<style>
208+ /* Additional custom styles if needed */
209+</style>⏎̸
A gui/src/components/omnibox/OmniboxFind.svelte +102 -0
@@ -0,0 +1,102 @@
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
58+ id="find-list"
59+ class="mt-2 max-h-[50vh] overflow-auto border-t border-[#333]"
60+ role="listbox"
61+ aria-label="Find results"
62+>
63+ <!-- Header with match count -->
64+ <div
65+ class="px-2.5 py-1.5 text-xs text-[#bbb]
66+ border-b border-[#2a2a2a]"
67+ >
68+ {#if totalMatches > 0}
69+ {totalMatches} match{totalMatches === 1 ? '' : 'es'}
70+ {:else}
71+ No matches
72+ {/if}
73+ </div>
74+
75+ <!-- Match results -->
76+ {#if hasContent}
77+ {#each matches as match, index (index)}
78+ {@const isSelected = index === selectedIndex}
79+
80+ <div
81+ id="find-item-{index}"
82+ class="px-2.5 py-2 cursor-pointer
83+ border-b border-[#2a2a2a] last:border-b-0
84+ {isSelected ? 'bg-[#0a0a0a]' : ''}"
85+ role="option"
86+ tabindex="-1"
87+ aria-selected={isSelected}
88+ onmouseenter={() => handleItemMouseEnter(index)}
89+ onclick={() => handleItemClick(index)}
90+ >
91+ <!-- Match context -->
92+ <div
93+ class="text-[#ddd] text-sm
94+ whitespace-nowrap overflow-hidden text-ellipsis"
95+ >
96+ {match.context || ''}
97+ </div>
98+ </div>
99+ {/each}
100+ {/if}
101+</div>
102+
A gui/src/components/omnibox/OmniboxInput.svelte +209 -0
@@ -0,0 +1,209 @@
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, omniboxBridge } from './messaging';
9+ import { findInPage } from './find';
10+
11+ // Reactive state
12+ let mode = $derived(omniboxStore.mode);
13+ let inputValue = $derived(omniboxStore.inputValue);
14+ let selectedIndex = $derived(omniboxStore.selectedIndex);
15+ let suggestions = $derived(omniboxStore.suggestions);
16+ let matches = $derived(omniboxStore.matches);
17+
18+ // Input element ref (props for parent to bind)
19+ interface Props {
20+ inputElement?: HTMLInputElement;
21+ }
22+
23+ let { inputElement = $bindable() }: Props = $props();
24+
25+ // Computed placeholder
26+ let placeholder = $derived(
27+ mode === 'find' ? 'Find in page…' : 'Type URL or search…'
28+ );
29+
30+ // Handle input changes
31+ function handleInput(event: Event) {
32+ const target = event.target as HTMLInputElement;
33+ const value = target.value || '';
34+
35+ omniboxStore.setInputValue(value);
36+ omniboxStore.setFaded(false);
37+
38+ if (mode === 'omnibox') {
39+ // Debounced search for omnibox
40+ debouncedQuery(value);
41+ } else if (mode === 'find') {
42+ // Immediate find for search
43+ findInPage(value);
44+ }
45+ }
46+
47+ // Handle key events
48+ function handleKeyDown(event: KeyboardEvent) {
49+ switch (event.key) {
50+ case 'Escape':
51+ omniboxStore.close();
52+ break;
53+
54+ case 'Enter':
55+ event.preventDefault();
56+ handleEnterKey(event);
57+ break;
58+
59+ case 'ArrowDown':
60+ case 'ArrowUp':
61+ event.preventDefault();
62+ event.stopPropagation();
63+ handleArrowKeys(event.key);
64+ break;
65+
66+ default:
67+ // Any other key should restore full opacity
68+ omniboxStore.setFaded(false);
69+ break;
70+ }
71+ }
72+
73+ // Handle Enter key based on mode
74+ function handleEnterKey(event: KeyboardEvent) {
75+ if (mode === 'omnibox') {
76+ const selectedSuggestion = selectedIndex >= 0 ? suggestions[selectedIndex] : null;
77+ const url = selectedSuggestion?.url || inputValue || '';
78+
79+ if (url) {
80+ omniboxBridge.navigate(url);
81+ omniboxStore.close();
82+ }
83+ } else if (mode === 'find') {
84+ if (event.shiftKey) {
85+ // Shift+Enter: jump to previous match
86+ jumpToMatch(-1);
87+ } else if (event.altKey) {
88+ // Alt+Enter: center on current match but keep overlay open
89+ revealCurrentMatch();
90+ } else {
91+ // Enter: center and close
92+ revealCurrentMatch();
93+ omniboxStore.close();
94+ }
95+ }
96+ }
97+
98+ // Handle arrow key navigation
99+ function handleArrowKeys(key: string) {
100+ const totalItems = mode === 'omnibox' ? suggestions.length : matches.length;
101+
102+ if (totalItems > 0) {
103+ if (key === 'ArrowDown') {
104+ omniboxStore.selectNext();
105+ } else {
106+ omniboxStore.selectPrevious();
107+ }
108+
109+ // Fade overlay while navigating results
110+ omniboxStore.setFaded(true);
111+
112+ // For find mode, reveal the current match
113+ if (mode === 'find') {
114+ revealCurrentMatch();
115+ }
116+ }
117+ }
118+
119+ // Jump to match (for find mode)
120+ function jumpToMatch(delta: number) {
121+ if (mode !== 'find' || matches.length === 0) return;
122+
123+ const currentIndex = selectedIndex || 0;
124+ const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
125+
126+ omniboxStore.setSelectedIndex(newIndex);
127+ revealCurrentMatch();
128+ }
129+
130+ // Reveal current match (for find mode)
131+ function revealCurrentMatch() {
132+ if (mode !== 'find' || selectedIndex < 0 || !matches[selectedIndex]) return;
133+
134+ // Remove previous active class
135+ const prevMatch = matches[omniboxStore.activeIndex];
136+ if (prevMatch?.element?.classList) {
137+ prevMatch.element.classList.remove('find-active');
138+ }
139+
140+ // Add active class to current match
141+ const currentMatch = matches[selectedIndex];
142+ if (currentMatch?.element?.classList) {
143+ currentMatch.element.classList.add('find-active');
144+ omniboxStore.setActiveIndex(selectedIndex);
145+
146+ // Scroll into view
147+ try {
148+ currentMatch.element.scrollIntoView({
149+ block: 'center',
150+ inline: 'nearest'
151+ });
152+ } catch {
153+ currentMatch.element.scrollIntoView();
154+ }
155+ }
156+ }
157+
158+ // Handle mouse events
159+ function handleMouseDown() {
160+ omniboxStore.setFaded(false);
161+ }
162+
163+ function handleClick() {
164+ omniboxStore.setFaded(false);
165+ }
166+
167+ function handleFocus() {
168+ omniboxStore.setFaded(false);
169+ }
170+
171+ function handleMouseEnter() {
172+ if (omniboxStore.visible && inputElement) {
173+ try {
174+ inputElement.focus({ preventScroll: true });
175+ inputElement.select();
176+ } catch {
177+ inputElement.focus();
178+ }
179+ }
180+ }
181+</script>
182+
183+<input
184+ bind:this={inputElement}
185+ type="text"
186+ {placeholder}
187+ value={inputValue}
188+ class="w-full box-border px-3 py-2.5
189+ bg-[#121212] text-[#eee]
190+ border border-[#555] rounded-md
191+ focus:outline-none
192+ text-base
193+ sm:px-2.5 sm:py-2 sm:text-sm
194+ xl:text-lg xl:px-3 xl:py-3"
195+ oninput={handleInput}
196+ onkeydown={handleKeyDown}
197+ onmousedown={handleMouseDown}
198+ onclick={handleClick}
199+ onfocus={handleFocus}
200+ onmouseenter={handleMouseEnter}
201+ autocomplete="off"
202+ spellcheck="false"
203+ role="combobox"
204+ aria-expanded={omniboxStore.hasContent}
205+ aria-controls="omnibox-list"
206+ aria-haspopup="listbox"
207+ aria-owns="omnibox-list"
208+ aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
209+/>⏎̸
A gui/src/components/omnibox/OmniboxSuggestions.svelte +144 -0
@@ -0,0 +1,144 @@
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 selectedIndex = $derived(omniboxStore.selectedIndex);
13+ let hasContent = $state(false);
14+
15+ // Update hasContent when suggestions change
16+ $effect(() => {
17+ hasContent = omniboxStore.mode === 'omnibox' && suggestions.length > 0;
18+ });
19+
20+ // Handle suggestion item mouse enter
21+ function handleItemMouseEnter(index: number) {
22+ omniboxStore.setSelectedIndex(index);
23+ omniboxStore.setFaded(true);
24+ scrollToSelection();
25+ }
26+
27+ // Handle suggestion item click
28+ function handleItemClick(suggestion: any) {
29+ omniboxBridge.navigate(suggestion.url);
30+ omniboxStore.close();
31+ }
32+
33+ // Scroll list to show selected item
34+ function scrollToSelection() {
35+ const selectedItem = document.getElementById(`omnibox-item-${selectedIndex}`);
36+ if (selectedItem && selectedItem.scrollIntoView) {
37+ try {
38+ selectedItem.scrollIntoView({ block: 'nearest' });
39+ } catch {
40+ selectedItem.scrollIntoView();
41+ }
42+ }
43+ }
44+
45+ // Parse URL for display
46+ function parseUrl(url: string) {
47+ try {
48+ const urlObj = new URL(url, window.location.href);
49+ return {
50+ domain: urlObj.hostname,
51+ path: (urlObj.pathname || '') + (urlObj.search || '') + (urlObj.hash || '')
52+ };
53+ } catch {
54+ return {
55+ domain: url || '',
56+ path: ''
57+ };
58+ }
59+ }
60+
61+ // Handle favicon error
62+ function handleFaviconError(event: Event) {
63+ const target = event.target as HTMLImageElement;
64+ const chip = target.parentElement;
65+ if (chip) {
66+ chip.style.display = 'none';
67+ }
68+ }
69+
70+ // Watch for selection changes to scroll
71+ $effect(() => {
72+ if (selectedIndex >= 0) {
73+ scrollToSelection();
74+ }
75+ });
76+</script>
77+
78+{#if hasContent}
79+ <div
80+ id="omnibox-list"
81+ class="mt-2 max-h-[50vh] overflow-auto border-t border-[#333]"
82+ role="listbox"
83+ aria-label="Search suggestions"
84+ >
85+ {#each suggestions as suggestion, index (suggestion.url)}
86+ {@const { domain, path } = parseUrl(suggestion.url)}
87+ {@const isSelected = index === selectedIndex}
88+
89+ <div
90+ id="omnibox-item-{index}"
91+ class="px-2.5 py-2 flex gap-2.5 items-center cursor-pointer
92+ border-b border-[#2a2a2a] last:border-b-0
93+ {isSelected ? 'bg-[#0a0a0a]' : ''}"
94+ role="option"
95+ tabindex="-1"
96+ aria-selected={isSelected}
97+ onmouseenter={() => handleItemMouseEnter(index)}
98+ onclick={() => handleItemClick(suggestion)}
99+ >
100+ <!-- Favicon chip -->
101+ {#if suggestion.favicon}
102+ <div
103+ class="flex-shrink-0 w-5 h-5 rounded-full
104+ bg-[#ccc] border border-black/12
105+ shadow-sm flex items-center justify-center"
106+ >
107+ <img
108+ src={suggestion.favicon}
109+ alt=""
110+ class="w-4 h-4 filter brightness-105 contrast-103"
111+ loading="lazy"
112+ onerror={handleFaviconError}
113+ style="image-rendering: -webkit-optimize-contrast;"
114+ />
115+ </div>
116+ {/if}
117+
118+ <!-- URL text with gradient fade -->
119+ <div
120+ class="flex-1 min-w-0 flex gap-2 items-center
121+ whitespace-nowrap overflow-hidden
122+ [mask-image:linear-gradient(90deg,black_85%,transparent_100%)]
123+ [-webkit-mask-image:linear-gradient(90deg,black_85%,transparent_100%)]"
124+ >
125+ <!-- Domain -->
126+ <span class="text-[#e6e6e6] opacity-95">
127+ {domain}
128+ </span>
129+
130+ <!-- Separator -->
131+ <span class="text-[#777]">
132+ {' | '}
133+ </span>
134+
135+ <!-- Path -->
136+ <span class="text-[#99aadd]">
137+ {path || '/'}
138+ </span>
139+ </div>
140+ </div>
141+ {/each}
142+ </div>
143+{/if}
144+
A gui/src/components/omnibox/find.ts +202 -0
@@ -0,0 +1,202 @@
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+/**
11+ * Find text in the current page and highlight matches
12+ */
13+export function findInPage(query: string): void {
14+ // Clear previous highlights
15+ omniboxStore.clearHighlights();
16+
17+ const trimmedQuery = (query || '').trim();
18+ if (!trimmedQuery) {
19+ return;
20+ }
21+
22+ const body = document.body;
23+ if (!body) {
24+ return;
25+ }
26+
27+ // Get omnibox root to exclude from search
28+ const omniboxRoot = document.getElementById('dumber-omnibox-root');
29+ const matches: FindMatch[] = [];
30+ const maxMatches = omniboxStore.config.maxMatches;
31+
32+ // Create tree walker to find text nodes
33+ const walker = document.createTreeWalker(
34+ body,
35+ NodeFilter.SHOW_TEXT,
36+ {
37+ acceptNode(node: Text): number {
38+ // Skip empty or whitespace-only nodes
39+ if (!node.nodeValue || !node.nodeValue.trim()) {
40+ return NodeFilter.FILTER_REJECT;
41+ }
42+
43+ const parentElement = node.parentElement;
44+ if (!parentElement) {
45+ return NodeFilter.FILTER_REJECT;
46+ }
47+
48+ // Skip omnibox content
49+ if (omniboxRoot && omniboxRoot.contains(parentElement)) {
50+ return NodeFilter.FILTER_REJECT;
51+ }
52+
53+ // Skip script, style, and noscript tags
54+ const tagName = parentElement.tagName;
55+ if (tagName === 'SCRIPT' || tagName === 'STYLE' || tagName === 'NOSCRIPT') {
56+ return NodeFilter.FILTER_REJECT;
57+ }
58+
59+ // Skip hidden elements
60+ const computedStyle = getComputedStyle(parentElement);
61+ if (computedStyle.visibility === 'hidden' || computedStyle.display === 'none') {
62+ return NodeFilter.FILTER_REJECT;
63+ }
64+
65+ return NodeFilter.FILTER_ACCEPT;
66+ }
67+ }
68+ );
69+
70+ const queryLowerCase = trimmedQuery.toLowerCase();
71+
72+ // Process each text node
73+ while (walker.nextNode() && matches.length < maxMatches) {
74+ const textNode = walker.currentNode as Text;
75+ let nodeValue = textNode.nodeValue || '';
76+ let searchIndex = 0;
77+
78+ // Find all matches in this text node
79+ while (nodeValue && matches.length < maxMatches) {
80+ const matchIndex = nodeValue.toLowerCase().indexOf(queryLowerCase, searchIndex);
81+ if (matchIndex === -1) {
82+ break;
83+ }
84+
85+ // Create text nodes for before, match, and after
86+ const beforeText = nodeValue.slice(0, matchIndex);
87+ const matchText = nodeValue.slice(matchIndex, matchIndex + trimmedQuery.length);
88+ const afterText = nodeValue.slice(matchIndex + trimmedQuery.length);
89+
90+ // Create elements
91+ const beforeNode = document.createTextNode(beforeText);
92+ const matchTextNode = document.createTextNode(matchText);
93+ const highlightSpan = document.createElement('span');
94+
95+ // Style the highlight span
96+ highlightSpan.className = 'find-highlight';
97+ highlightSpan.appendChild(matchTextNode);
98+
99+ // Insert into DOM
100+ const parent = textNode.parentNode;
101+ if (!parent) {
102+ break;
103+ }
104+
105+ parent.insertBefore(beforeNode, textNode);
106+ parent.insertBefore(highlightSpan, textNode);
107+ textNode.nodeValue = afterText;
108+
109+ // Track highlight node for cleanup
110+ const highlightNode: HighlightNode = {
111+ span: highlightSpan,
112+ text: matchTextNode
113+ };
114+ omniboxStore.addHighlightNode(highlightNode);
115+
116+ // Create match context
117+ const leftContext = beforeText.slice(-30);
118+ let rightContext = afterText.slice(0, 30);
119+
120+ // Try to break at word boundaries
121+ const boundaryMatch = rightContext.match(/[.,;:-]/);
122+ if (boundaryMatch && boundaryMatch.index !== undefined) {
123+ rightContext = rightContext.slice(0, boundaryMatch.index + 1);
124+ }
125+
126+ const context = (leftContext + matchText + rightContext)
127+ .replace(/\s+/g, ' ')
128+ .trim();
129+
130+ // Add to matches
131+ const findMatch: FindMatch = {
132+ element: highlightSpan,
133+ context
134+ };
135+ matches.push(findMatch);
136+
137+ // Continue searching in the remaining text
138+ nodeValue = afterText;
139+ searchIndex = 0;
140+ }
141+ }
142+
143+ // Update store with matches
144+ omniboxStore.updateMatches(matches);
145+
146+ // Reveal first match if any
147+ if (matches.length > 0) {
148+ revealMatch(0);
149+ }
150+}
151+
152+/**
153+ * Reveal a specific match by index
154+ */
155+export function revealMatch(index: number): void {
156+ const matches = omniboxStore.matches;
157+ if (index < 0 || index >= matches.length) {
158+ return;
159+ }
160+
161+ // Remove previous active class
162+ const prevActiveIndex = omniboxStore.activeIndex;
163+ if (prevActiveIndex >= 0 && matches[prevActiveIndex]) {
164+ const prevElement = matches[prevActiveIndex].element;
165+ if (prevElement.classList) {
166+ prevElement.classList.remove('find-active');
167+ }
168+ }
169+
170+ // Add active class to current match
171+ const currentMatch = matches[index];
172+ if (currentMatch && currentMatch.element.classList) {
173+ currentMatch.element.classList.add('find-active');
174+ omniboxStore.setActiveIndex(index);
175+
176+ // Scroll into view
177+ try {
178+ currentMatch.element.scrollIntoView({
179+ block: 'center',
180+ inline: 'nearest'
181+ });
182+ } catch {
183+ currentMatch.element.scrollIntoView();
184+ }
185+ }
186+}
187+
188+/**
189+ * Jump to next/previous match
190+ */
191+export function jumpToMatch(delta: number): void {
192+ const matches = omniboxStore.matches;
193+ if (matches.length === 0) {
194+ return;
195+ }
196+
197+ const currentIndex = omniboxStore.selectedIndex || 0;
198+ const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
199+
200+ omniboxStore.setSelectedIndex(newIndex);
201+ revealMatch(newIndex);
202+}⏎̸
A gui/src/components/omnibox/index.ts +16 -0
@@ -0,0 +1,16 @@
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';⏎̸
A gui/src/components/omnibox/messaging.ts +95 -0
@@ -0,0 +1,95 @@
1+/**
2+ * Omnibox Message Bridge
3+ *
4+ * TypeScript interface for Go-JavaScript communication
5+ */
6+
7+import type { OmniboxMessage, OmniboxMessageBridge, Suggestion } from './types';
8+import { omniboxStore } from './stores.svelte.ts';
9+
10+export class OmniboxBridge implements OmniboxMessageBridge {
11+ /**
12+ * Send message to Go backend via WebKit message handler
13+ */
14+ postMessage(msg: OmniboxMessage): void {
15+ try {
16+ console.log('📨 Sending message to Go backend:', msg);
17+ const messageHandler = window.webkit?.messageHandlers?.dumber;
18+ if (messageHandler && messageHandler.postMessage) {
19+ const jsonMsg = JSON.stringify(msg);
20+ console.log('📨 JSON message:', jsonMsg);
21+ messageHandler.postMessage(jsonMsg);
22+ } else {
23+ console.warn('WebKit message handler not available');
24+ }
25+ } catch (error) {
26+ console.error('Failed to send message to Go backend:', error);
27+ }
28+ }
29+
30+ /**
31+ * Update suggestions from Go backend
32+ */
33+ setSuggestions(suggestions: Suggestion[]): void {
34+ omniboxStore.updateSuggestions(suggestions);
35+ }
36+
37+ /**
38+ * Handle navigation request
39+ */
40+ navigate(url: string): void {
41+ console.log('🚀 Omnibox navigate called with:', url);
42+ this.postMessage({
43+ type: 'navigate',
44+ url
45+ });
46+ }
47+
48+ /**
49+ * Handle search query
50+ */
51+ query(searchTerm: string, limit?: number): void {
52+ this.postMessage({
53+ type: 'query',
54+ q: searchTerm,
55+ limit: limit || omniboxStore.config.defaultLimit
56+ });
57+ }
58+}
59+
60+// Singleton instance
61+export const omniboxBridge = new OmniboxBridge();
62+
63+/**
64+ * Debounced query function for search input
65+ */
66+export function debouncedQuery(searchTerm: string): void {
67+ omniboxStore.clearDebounceTimer();
68+
69+ const timerId = window.setTimeout(() => {
70+ omniboxBridge.query(searchTerm);
71+ }, omniboxStore.config.debounceDelay);
72+
73+ omniboxStore.setDebounceTimer(timerId);
74+}
75+
76+// Extend global window interface for Go bridge compatibility
77+declare global {
78+ interface Window {
79+ webkit?: {
80+ messageHandlers?: {
81+ dumber?: {
82+ postMessage: (message: string) => void;
83+ };
84+ };
85+ };
86+ // Omnibox API for Go bridge
87+ __dumber_omnibox?: {
88+ setSuggestions: (suggestions: Suggestion[]) => void;
89+ toggle: () => void;
90+ open: (mode?: string, query?: string) => void;
91+ close: () => void;
92+ findQuery: (query: string) => void;
93+ };
94+ }
95+}⏎̸
A gui/src/components/omnibox/stores.svelte.ts +215 -0
@@ -0,0 +1,215 @@
1+/**
2+ * Omnibox State Management
3+ *
4+ * Svelte 5 rune-based state management for omnibox functionality
5+ */
6+
7+import type {
8+ OmniboxMode,
9+ Suggestion,
10+ FindMatch,
11+ HighlightNode,
12+ OmniboxConfig
13+} from './types';
14+
15+// Default configuration
16+const DEFAULT_CONFIG: Required<OmniboxConfig> = {
17+ maxMatches: 2000,
18+ debounceDelay: 120,
19+ defaultLimit: 10
20+};
21+
22+// Global state using Svelte 5 runes
23+let visible = $state(false);
24+let mode = $state<OmniboxMode>('omnibox');
25+let suggestions = $state<Suggestion[]>([]);
26+let matches = $state<FindMatch[]>([]);
27+let selectedIndex = $state(-1);
28+let activeIndex = $state(-1);
29+let faded = $state(false);
30+let inputValue = $state('');
31+let highlightNodes = $state<HighlightNode[]>([]);
32+let prevOverflow = $state('');
33+let debounceTimer = $state(0);
34+
35+// Configuration
36+let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG);
37+
38+export const omniboxStore = {
39+ // State getters
40+ get visible() { return visible; },
41+ get mode() { return mode; },
42+ get suggestions() { return suggestions; },
43+ get matches() { return matches; },
44+ get selectedIndex() { return selectedIndex; },
45+ get activeIndex() { return activeIndex; },
46+ get faded() { return faded; },
47+ get inputValue() { return inputValue; },
48+ get highlightNodes() { return highlightNodes; },
49+ get prevOverflow() { return prevOverflow; },
50+ get config() { return config; },
51+
52+ // Computed getters
53+ get hasContent() {
54+ return (mode === 'omnibox' && suggestions.length > 0) ||
55+ (mode === 'find' && matches.length > 0);
56+ },
57+ get totalItems() {
58+ return mode === 'omnibox' ? suggestions.length : matches.length;
59+ },
60+ get selectedItem() {
61+ if (selectedIndex < 0) return null;
62+ return mode === 'omnibox' ? suggestions[selectedIndex] : matches[selectedIndex];
63+ },
64+
65+ // Actions
66+ setVisible(value: boolean) {
67+ if (value && !visible) {
68+ // Store scroll state when opening
69+ prevOverflow = document.documentElement.style.overflow;
70+ document.documentElement.style.overflow = 'hidden';
71+ } else if (!value && visible) {
72+ // Restore scroll state when closing
73+ document.documentElement.style.overflow = prevOverflow || '';
74+ prevOverflow = '';
75+
76+ // Clear find highlights when closing
77+ if (mode === 'find') {
78+ this.clearHighlights();
79+ }
80+ }
81+ visible = value;
82+ },
83+
84+ setMode(newMode: OmniboxMode) {
85+ // Clear previous mode's state
86+ if (newMode === 'omnibox') {
87+ // Switching to omnibox mode - clear find state
88+ this.clearHighlights();
89+ } else if (newMode === 'find') {
90+ // Switching to find mode - clear suggestions
91+ this.updateSuggestions([]);
92+ }
93+
94+ mode = newMode;
95+ selectedIndex = -1;
96+ faded = false;
97+ inputValue = ''; // Clear input for fresh start
98+ },
99+
100+ setInputValue(value: string) {
101+ inputValue = value;
102+ },
103+
104+ setFaded(value: boolean) {
105+ faded = value;
106+ },
107+
108+ setSelectedIndex(index: number) {
109+ const maxIndex = this.totalItems - 1;
110+ if (maxIndex < 0) {
111+ selectedIndex = -1;
112+ } else {
113+ selectedIndex = Math.max(0, Math.min(index, maxIndex));
114+ }
115+ },
116+
117+ selectNext() {
118+ if (this.totalItems === 0) return;
119+ const newIndex = selectedIndex < 0 ? 0 : (selectedIndex + 1) % this.totalItems;
120+ this.setSelectedIndex(newIndex);
121+ faded = true;
122+ },
123+
124+ selectPrevious() {
125+ if (this.totalItems === 0) return;
126+ const newIndex = selectedIndex <= 0 ? this.totalItems - 1 : selectedIndex - 1;
127+ this.setSelectedIndex(newIndex);
128+ faded = true;
129+ },
130+
131+ open(newMode: OmniboxMode = 'omnibox', initialQuery?: string) {
132+ this.setMode(newMode);
133+ this.setVisible(true);
134+ if (typeof initialQuery === 'string') {
135+ inputValue = initialQuery;
136+ }
137+ },
138+
139+ close() {
140+ this.setVisible(false);
141+ faded = false;
142+ },
143+
144+ toggle() {
145+ this.setVisible(!visible);
146+ },
147+
148+ updateSuggestions(newSuggestions: Suggestion[]) {
149+ suggestions = Array.isArray(newSuggestions) ? newSuggestions : [];
150+ selectedIndex = -1;
151+ },
152+
153+ updateMatches(newMatches: FindMatch[]) {
154+ matches = Array.isArray(newMatches) ? newMatches : [];
155+ selectedIndex = newMatches.length > 0 ? 0 : -1;
156+ },
157+
158+ clearHighlights() {
159+ try {
160+ highlightNodes.forEach(({ span, text }) => {
161+ const parent = span.parentNode;
162+ if (parent) {
163+ parent.replaceChild(text, span);
164+ parent.normalize();
165+ }
166+ });
167+ } catch (error) {
168+ console.warn('Error clearing highlights:', error);
169+ }
170+ highlightNodes = [];
171+ matches = [];
172+ selectedIndex = -1;
173+ activeIndex = -1;
174+ },
175+
176+ addHighlightNode(node: HighlightNode) {
177+ highlightNodes = [...highlightNodes, node];
178+ },
179+
180+ setActiveIndex(index: number) {
181+ activeIndex = index;
182+ },
183+
184+ setDebounceTimer(timerId: number) {
185+ if (debounceTimer) {
186+ clearTimeout(debounceTimer);
187+ }
188+ debounceTimer = timerId;
189+ },
190+
191+ clearDebounceTimer() {
192+ if (debounceTimer) {
193+ clearTimeout(debounceTimer);
194+ debounceTimer = 0;
195+ }
196+ },
197+
198+ updateConfig(newConfig: Partial<OmniboxConfig>) {
199+ config = { ...config, ...newConfig };
200+ },
201+
202+ reset() {
203+ visible = false;
204+ mode = 'omnibox';
205+ suggestions = [];
206+ matches = [];
207+ selectedIndex = -1;
208+ activeIndex = -1;
209+ faded = false;
210+ inputValue = '';
211+ prevOverflow = '';
212+ this.clearHighlights();
213+ this.clearDebounceTimer();
214+ }
215+};⏎̸
A gui/src/components/omnibox/types.ts +63 -0
@@ -0,0 +1,63 @@
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 OmniboxMessage {
13+ type: 'navigate' | 'query';
14+ url?: string;
15+ q?: string;
16+ limit?: number;
17+}
18+
19+export interface FindMatch {
20+ element: HTMLElement;
21+ context: string;
22+}
23+
24+export interface HighlightNode {
25+ span: HTMLElement;
26+ text: Text;
27+}
28+
29+export type OmniboxMode = 'omnibox' | 'find';
30+
31+export interface OmniboxConfig {
32+ maxMatches?: number;
33+ debounceDelay?: number;
34+ defaultLimit?: number;
35+}
36+
37+export interface OmniboxState {
38+ visible: boolean;
39+ mode: OmniboxMode;
40+ suggestions: Suggestion[];
41+ matches: FindMatch[];
42+ selectedIndex: number;
43+ activeIndex: number;
44+ faded: boolean;
45+ inputValue: string;
46+ highlightNodes: HighlightNode[];
47+ prevOverflow: string;
48+}
49+
50+// Message bridge interface for Go communication
51+export interface OmniboxMessageBridge {
52+ postMessage(msg: OmniboxMessage): void;
53+ setSuggestions(suggestions: Suggestion[]): void;
54+}
55+
56+// Global API for Go bridge
57+export interface OmniboxAPI {
58+ setSuggestions: (suggestions: Suggestion[]) => void;
59+ toggle: () => void;
60+ open: (mode?: OmniboxMode, query?: string) => void;
61+ close: () => void;
62+ findQuery: (query: string) => void;
63+}⏎̸
A gui/src/components/toast/Toast.svelte +121 -0
@@ -0,0 +1,121 @@
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 timeoutId: number | undefined;
16+
17+ onMount(() => {
18+ timeoutId = window.setTimeout(() => {
19+ dismiss();
20+ }, duration);
21+ });
22+
23+ onDestroy(() => {
24+ if (timeoutId) {
25+ window.clearTimeout(timeoutId);
26+ timeoutId = undefined;
27+ }
28+ });
29+
30+ function dismiss() {
31+ if (timeoutId) {
32+ window.clearTimeout(timeoutId);
33+ timeoutId = undefined;
34+ }
35+ isExiting = true;
36+ // Allow time for exit animation before removing from parent
37+ setTimeout(() => {
38+ onDismiss(id);
39+ }, 250);
40+ }
41+
42+ function handleClick() {
43+ dismiss();
44+ }
45+</script>
46+
47+<button
48+ class="toast-base {type === 'success' ? 'toast-success' : type === 'error' ? 'toast-error' : 'toast-info'} {isExiting ? 'exiting' : ''}"
49+ onclick={handleClick}
50+ data-toast-id={id}
51+ type="button"
52+ aria-label="Dismiss notification"
53+ title="Click to dismiss"
54+>
55+ <div class="w-full min-w-36 px-5 py-4 text-center text-white font-semibold text-sm rounded-xl shadow-2xl backdrop-blur-md border transition-all duration-250 {type === 'success' ? 'bg-gradient-to-br from-green-500/90 to-green-700/90 border-green-300/30' : type === 'error' ? 'bg-gradient-to-br from-red-500/90 to-red-700/90 border-red-300/30' : 'bg-gradient-to-br from-blue-500/90 to-blue-700/90 border-blue-300/30'}">
56+ {message || 'Notification'}
57+ </div>
58+</button>
59+
60+<style>
61+ .toast-base {
62+ /* Reset button styles */
63+ border: none;
64+ background: none;
65+ padding: 0;
66+ font: inherit;
67+ text-align: left;
68+
69+ /* Toast functionality */
70+ cursor: pointer;
71+ user-select: none;
72+ position: relative;
73+ overflow: visible;
74+
75+ /* Layout */
76+ display: block;
77+ visibility: visible;
78+ z-index: 2147483647;
79+ pointer-events: auto;
80+
81+ /* Animations */
82+ animation: slideIn 0.3s ease-out forwards;
83+ transition: transform 0.2s ease, filter 0.2s ease;
84+ }
85+
86+ .toast-base.exiting {
87+ animation: slideOut 0.25s ease-in forwards;
88+ }
89+
90+ @keyframes slideIn {
91+ from {
92+ transform: translateX(-100%);
93+ opacity: 0;
94+ }
95+ to {
96+ transform: translateX(0);
97+ opacity: 1;
98+ }
99+ }
100+
101+ @keyframes slideOut {
102+ from {
103+ transform: translateX(0);
104+ opacity: 1;
105+ }
106+ to {
107+ transform: translateX(-100%);
108+ opacity: 0;
109+ }
110+ }
111+
112+ .toast-base:hover {
113+ filter: brightness(1.1);
114+ transform: scale(1.02);
115+ }
116+
117+ .toast-base:active {
118+ transform: scale(0.98);
119+ }
120+
121+</style>⏎̸
A gui/src/components/toast/ToastContainer.svelte +144 -0
@@ -0,0 +1,144 @@
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+
53+ function showZoomToast(zoomLevel: number) {
54+ console.log('[dumber] showZoomToast called with level:', zoomLevel);
55+
56+ // Debug: Check if toast container exists in DOM
57+ const container = document.querySelector('.toast-container');
58+ const browserRoot = document.querySelector('.browser-component-root');
59+ console.log('[dumber] DOM check - container exists:', !!container, 'browser-root exists:', !!browserRoot, 'toasts array length:', toasts.length);
60+
61+ // Clear existing debounce timer
62+ if (zoomDebounceTimer) {
63+ window.clearTimeout(zoomDebounceTimer);
64+ }
65+
66+ // Remove existing zoom toasts immediately (no animation needed for replacement)
67+ toasts = toasts.filter(toast => {
68+ const isZoomToast = toast.message.includes('Zoom level:') || toast.id === zoomToastId;
69+ return !isZoomToast;
70+ });
71+
72+ // Show new zoom toast
73+ const percentage = Math.round(zoomLevel * 100);
74+ const message = `Zoom level: ${percentage}%`;
75+ const id = addToast(message, 1500, 'info');
76+ zoomToastId = id;
77+
78+ console.log('[dumber] Added zoom toast - id:', id, 'current toasts count:', toasts.length);
79+
80+ // Debounced cleanup for rapid zoom changes
81+ zoomDebounceTimer = window.setTimeout(() => {
82+ zoomToastId = null;
83+ zoomDebounceTimer = undefined;
84+ }, 1500);
85+ }
86+
87+ // Expose API to global window for Go integration immediately
88+ // This must happen outside onMount to ensure functions are available immediately
89+ if (typeof window !== 'undefined') {
90+ window.__dumber_showToast = addToast;
91+ window.__dumber_dismissToast = dismissToast;
92+ window.__dumber_clearToasts = clearAllToasts;
93+ window.__dumber_showZoomToast = showZoomToast;
94+ console.log('✅ Toast functions exposed to window:', {
95+ showToast: typeof window.__dumber_showToast,
96+ dismissToast: typeof window.__dumber_dismissToast,
97+ clearToasts: typeof window.__dumber_clearToasts,
98+ showZoomToast: typeof window.__dumber_showZoomToast
99+ });
100+ }
101+
102+ // Effect that runs when toasts array changes
103+ $effect(() => {
104+ const container = document.querySelector('.toast-container');
105+ const browserRoot = document.querySelector('.browser-component-root');
106+ console.log('[dumber] Toast state changed - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length, 'toasts:', toasts.map(t => t.message));
107+ });
108+
109+ // One-time setup effect
110+ $effect(() => {
111+ console.log('🔄 ToastContainer initialized, functions exposed');
112+
113+ // Monitor container periodically for debugging
114+ const monitorInterval = setInterval(() => {
115+ const container = document.querySelector('.toast-container');
116+ const browserRoot = document.querySelector('.browser-component-root');
117+ console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);
118+ }, 5000);
119+
120+ return () => {
121+ clearInterval(monitorInterval);
122+ console.log('🗑️ ToastContainer cleanup');
123+ };
124+ });
125+</script>
126+
127+<div class="fixed top-5 left-5 z-[2147483647] pointer-events-none flex flex-col gap-2 max-w-80 visible opacity-100 backdrop-blur-sm">
128+ {#each toasts as toast (toast.id)}
129+ <Toast
130+ id={toast.id}
131+ message={toast.message}
132+ duration={toast.duration}
133+ type={toast.type}
134+ onDismiss={dismissToast}
135+ />
136+ {/each}
137+</div>
138+
139+<style>
140+ :global(.toast-base) {
141+ pointer-events: auto;
142+ will-change: transform, opacity;
143+ }
144+</style>⏎̸
A gui/src/injected/gui.ts +223 -0
@@ -0,0 +1,223 @@
1+/**
2+ * Unified GUI Bundle Entry Point
3+ *
4+ * Single entry point for all WebKit GUI functionality.
5+ * Loads immediately and provides modular initialization system.
6+ */
7+
8+// Import all GUI modules
9+import { initializeToast, type ToastConfig } from './modules/toast';
10+import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox';
11+import { keyboardService, type KeyboardService } from '../lib/keyboard';
12+
13+// Global interface for the unified GUI system
14+interface DumberGUI {
15+ initializeToast: (config?: ToastConfig) => Promise<void>;
16+ initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>;
17+ keyboard: KeyboardService;
18+ isReady: boolean;
19+}
20+
21+// DOM readiness utility - handles all document ready states
22+function whenDOMReady(callback: () => void) {
23+ if (document.readyState === 'loading') {
24+ document.addEventListener('DOMContentLoaded', callback, { once: true });
25+ } else if (document.readyState === 'interactive' && !document.body) {
26+ // Interactive but no body yet, wait a bit more
27+ setTimeout(() => whenDOMReady(callback), 10);
28+ } else {
29+ // DOM ready and body exists
30+ callback();
31+ }
32+}
33+
34+// Create global namespace
35+declare global {
36+ interface Window {
37+ __dumber_gui?: DumberGUI;
38+ __dumber_gui_ready?: boolean;
39+ __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void;
40+ __dumber_showZoomToast?: (level: number) => void;
41+ __dumber_keyboard?: KeyboardService;
42+ // Legacy compatibility functions for Go bridge
43+ __dumber_toggle?: () => void;
44+ __dumber_find_open?: (query?: string) => void;
45+ __dumber_find_close?: () => void;
46+ __dumber_setSuggestions?: (suggestions: any[]) => void;
47+ __dumber_find_query?: (query: string) => void;
48+ }
49+}
50+
51+// Prevent multiple initialization with enhanced logging
52+if (!window.__dumber_gui_ready) {
53+ // Skip initialization in iframes - GUI only needed in main frame
54+ if (window.self !== window.top) {
55+ console.log('🚫 Dumber GUI skipped in iframe');
56+ } else {
57+ window.__dumber_gui_ready = true;
58+
59+ // Initialize Svelte GUI systems immediately
60+ whenDOMReady(async () => {
61+ try {
62+ // Initialize toast system first
63+ await initializeToast();
64+
65+ // Verify toast functions are available
66+ if (typeof window.__dumber_showToast !== 'function') {
67+ throw new Error('Toast functions not exposed after initialization');
68+ }
69+
70+ // Initialize omnibox system
71+ await initializeOmnibox();
72+
73+ console.log('✅ All GUI systems initialized successfully');
74+ } catch (e) {
75+ console.error('❌ Failed to initialize Svelte toast system:', e);
76+
77+ // Enhanced fallback with better error recovery
78+ window.__dumber_showToast = (message: string, duration: number = 2500) => {
79+ // Remove existing toasts to prevent overlap
80+ document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());
81+
82+ const toast = document.createElement('div');
83+ toast.className = 'dumber-fallback-toast';
84+ toast.textContent = message;
85+ toast.style.cssText = `
86+ position: fixed;
87+ bottom: 20px;
88+ right: 20px;
89+ background: rgba(0,0,0,0.9);
90+ color: white;
91+ padding: 12px 16px;
92+ border-radius: 6px;
93+ z-index: 2147483647;
94+ font-family: system-ui, -apple-system, sans-serif;
95+ font-size: 14px;
96+ opacity: 0;
97+ transform: translateX(100%);
98+ transition: all 0.3s ease;
99+ max-width: 300px;
100+ word-wrap: break-word;
101+ `;
102+
103+ if (document.body) {
104+ document.body.appendChild(toast);
105+
106+ // Animate in
107+ requestAnimationFrame(() => {
108+ toast.style.opacity = '1';
109+ toast.style.transform = 'translateX(0)';
110+ });
111+
112+ // Animate out and remove
113+ setTimeout(() => {
114+ toast.style.opacity = '0';
115+ toast.style.transform = 'translateX(100%)';
116+ setTimeout(() => toast.remove(), 300);
117+ }, duration);
118+
119+ }
120+ };
121+
122+ window.__dumber_showZoomToast = (level: number) => {
123+ const percentage = Math.round(level * 100);
124+ window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500);
125+ };
126+
127+ // Expose other required functions
128+ window.__dumber_dismissToast = () => {
129+ document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());
130+ };
131+
132+ window.__dumber_clearToasts = () => {
133+ document.querySelectorAll('.dumber-fallback-toast').forEach(el => el.remove());
134+ };
135+ }
136+ });
137+
138+ // Expose keyboard service globally for Go bridge
139+ window.__dumber_keyboard = keyboardService;
140+
141+ // Initialize global keyboard and mouse event listeners for the keyboard service
142+ whenDOMReady(() => {
143+ // Global keyboard event listener (capture phase for high priority)
144+ document.addEventListener('keydown', (event) => {
145+ keyboardService.handleKeyboardEvent(event);
146+ }, true);
147+
148+ // Global mouse event listener for navigation buttons
149+ document.addEventListener('mousedown', (event) => {
150+ keyboardService.handleMouseEvent(event);
151+ }, true);
152+
153+ console.log('✅ KeyboardService initialized with global listeners');
154+ });
155+
156+ // Legacy compatibility functions for existing Go code
157+ window.__dumber_toggle = () => {
158+ try {
159+ console.log('🎯 __dumber_toggle called');
160+ console.log('🔧 Omnibox API available:', !!window.__dumber_omnibox);
161+ if (window.__dumber_omnibox?.toggle) {
162+ console.log('✅ Using Svelte omnibox toggle');
163+ window.__dumber_omnibox.toggle();
164+ } else {
165+ console.log('⚠️ Falling back to keyboard service');
166+ keyboardService.handleNativeShortcut('cmdorctrl+l');
167+ }
168+ } catch (error) {
169+ console.error('❌ Error in __dumber_toggle:', error);
170+ }
171+ };
172+
173+ window.__dumber_find_open = (query?: string) => {
174+ try {
175+ if (window.__dumber_omnibox?.open) {
176+ window.__dumber_omnibox.open('find', query);
177+ } else {
178+ keyboardService.handleNativeShortcut('cmdorctrl+f');
179+ }
180+ } catch (error) {
181+ console.error('❌ Error in __dumber_find_open:', error);
182+ }
183+ };
184+
185+ window.__dumber_find_close = () => {
186+ if (window.__dumber_omnibox?.close) {
187+ window.__dumber_omnibox.close();
188+ } else {
189+ keyboardService.handleNativeShortcut('escape');
190+ }
191+ };
192+
193+ // Key function that Go uses to send search suggestions
194+ window.__dumber_setSuggestions = (suggestions: any[]) => {
195+ if (window.__dumber_omnibox?.setSuggestions) {
196+ window.__dumber_omnibox.setSuggestions(suggestions);
197+ }
198+ };
199+
200+ // Find query function that Go uses to set find query
201+ window.__dumber_find_query = (query: string) => {
202+ if (window.__dumber_omnibox?.findQuery) {
203+ window.__dumber_omnibox.findQuery(query);
204+ }
205+ };
206+
207+ // Create the global GUI object
208+ window.__dumber_gui = {
209+ initializeToast: async (config?: ToastConfig) => {
210+ return await initializeToast(config);
211+ },
212+
213+ initializeOmnibox: async (config?: OmniboxInitConfig) => {
214+ return await initializeOmnibox(config);
215+ },
216+
217+ keyboard: keyboardService,
218+
219+ isReady: true
220+ };
221+
222+ }
223+}⏎̸
A gui/src/injected/modules/controls.ts +109 -0
@@ -0,0 +1,109 @@
1+/**
2+ * Browser Controls Module
3+ *
4+ * Provides global keyboard and mouse controls that work on all domains.
5+ * Only includes essential controls that can work cross-origin:
6+ * - Navigation (back/forward)
7+ * - Mouse navigation buttons
8+ *
9+ * Zoom controls are handled natively by the GTK backend
10+ */
11+
12+export interface ControlsConfig {
13+ // Future configuration options can be added here
14+ _placeholder?: never; // Prevent empty interface warning
15+}
16+
17+let controlsInitialized = false;
18+
19+export function initializeControls(_config?: ControlsConfig): void {
20+ if (controlsInitialized) {
21+ console.log('🔄 Browser controls already initialized, skipping');
22+ return;
23+ }
24+
25+ console.log('🚀 Initializing browser controls on:', window.location.href);
26+
27+ // Keyboard event handler for global controls
28+ function handleKeyboardEvent(event: KeyboardEvent): void {
29+ const { altKey, key } = event;
30+ // Note: ctrlKey, metaKey reserved for future use
31+
32+ try {
33+ // Navigation: Alt + Left Arrow (Back)
34+ if (altKey && key === 'ArrowLeft') {
35+ event.preventDefault();
36+ event.stopPropagation();
37+
38+ if (window.history.length > 1) {
39+ console.log('⬅️ Navigating back');
40+ window.history.back();
41+ }
42+ return;
43+ }
44+
45+ // Navigation: Alt + Right Arrow (Forward)
46+ if (altKey && key === 'ArrowRight') {
47+ event.preventDefault();
48+ event.stopPropagation();
49+
50+ console.log('➡️ Navigating forward');
51+ window.history.forward();
52+ return;
53+ }
54+
55+ } catch (error) {
56+ console.error('❌ Error in keyboard handler:', error);
57+ }
58+ }
59+
60+ // Mouse event handler for navigation buttons
61+ function handleMouseEvent(event: MouseEvent): void {
62+ try {
63+ // Mouse button 3 (back button)
64+ if (event.button === 3) {
65+ event.preventDefault();
66+ event.stopPropagation();
67+
68+ if (window.history.length > 1) {
69+ console.log('⬅️ Mouse back button pressed');
70+ window.history.back();
71+ }
72+ return;
73+ }
74+
75+ // Mouse button 4 (forward button)
76+ if (event.button === 4) {
77+ event.preventDefault();
78+ event.stopPropagation();
79+
80+ console.log('➡️ Mouse forward button pressed');
81+ window.history.forward();
82+ return;
83+ }
84+
85+ } catch (error) {
86+ console.error('❌ Error in mouse handler:', error);
87+ }
88+ }
89+
90+ // Add event listeners with high priority (capture phase)
91+ try {
92+ document.addEventListener('keydown', handleKeyboardEvent, true);
93+ document.addEventListener('mousedown', handleMouseEvent, true);
94+
95+ controlsInitialized = true;
96+ console.log('✅ Browser controls initialized');
97+
98+ // Cleanup function for when page is unloaded
99+ window.addEventListener('beforeunload', () => {
100+ document.removeEventListener('keydown', handleKeyboardEvent, true);
101+ document.removeEventListener('mousedown', handleMouseEvent, true);
102+ controlsInitialized = false;
103+ console.log('🧹 Browser controls cleaned up');
104+ });
105+
106+ } catch (error) {
107+ console.error('❌ Failed to initialize browser controls:', error);
108+ }
109+}⏎̸
A gui/src/injected/modules/omnibox.ts +105 -0
@@ -0,0 +1,105 @@
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 type { OmniboxConfig } from '../../components/omnibox/types';
10+
11+let omniboxComponent: ReturnType<typeof mount> | null = null;
12+let isInitialized = false;
13+
14+export interface OmniboxInitConfig extends OmniboxConfig {
15+ // Additional initialization options if needed
16+ containerId?: string;
17+}
18+
19+/**
20+ * Initialize the omnibox component system
21+ */
22+export async function initializeOmnibox(config: OmniboxInitConfig = {}): Promise<void> {
23+ if (isInitialized) {
24+ console.warn('Omnibox already initialized');
25+ return;
26+ }
27+
28+ try {
29+ // Ensure we're in the main frame (not an iframe)
30+ if (window.self !== window.top) {
31+ console.log('🚫 Omnibox skipped in iframe');
32+ return;
33+ }
34+
35+ // Check if DOM is ready
36+ if (!document.body) {
37+ console.warn('DOM body not ready for omnibox initialization');
38+ return;
39+ }
40+
41+ // Create container element
42+ const containerId = config.containerId || 'dumber-omnibox-root';
43+ let container = document.getElementById(containerId);
44+
45+ if (!container) {
46+ container = document.createElement('div');
47+ container.id = containerId;
48+ container.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;';
49+ document.documentElement.appendChild(container);
50+ }
51+
52+ // Mount the Svelte component
53+ omniboxComponent = mount(Omnibox, {
54+ target: container
55+ });
56+
57+ // Wait for the component to mount and set up the global API
58+ await new Promise<void>((resolve) => {
59+ const checkAPI = () => {
60+ if (window.__dumber_omnibox && typeof window.__dumber_omnibox === 'object') {
61+ console.log('✅ Omnibox component system initialized');
62+ console.log('🔧 Global API is available:', Object.keys(window.__dumber_omnibox));
63+ isInitialized = true;
64+ resolve();
65+ } else {
66+ setTimeout(checkAPI, 10); // Check again in 10ms
67+ }
68+ };
69+ checkAPI();
70+ });
71+
72+ } catch (error) {
73+ console.error('❌ Failed to initialize omnibox component:', error);
74+ throw error;
75+ }
76+}
77+
78+/**
79+ * Get the current initialization status
80+ */
81+export function isOmniboxInitialized(): boolean {
82+ return isInitialized;
83+}
84+
85+/**
86+ * Cleanup omnibox component (useful for hot reload during development)
87+ */
88+export function cleanupOmnibox(): void {
89+ if (omniboxComponent) {
90+ try {
91+ omniboxComponent.$destroy?.();
92+ } catch (error) {
93+ console.warn('Error destroying omnibox component:', error);
94+ }
95+ omniboxComponent = null;
96+ }
97+
98+ const container = document.getElementById('dumber-omnibox-root');
99+ if (container) {
100+ container.remove();
101+ }
102+
103+ isInitialized = false;
104+ console.log('🧹 Omnibox component cleaned up');
105+}⏎̸
A gui/src/injected/modules/toast.ts +147 -0
@@ -0,0 +1,147 @@
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 '$lib/styles.css';
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 (typeof window.__dumber_showToast === 'function' &&
39+ typeof window.__dumber_showZoomToast === 'function') {
40+ console.log('✅ Toast system already initialized and functional');
41+ return;
42+ }
43+ // If functions are missing, reinitialize
44+ console.log('⚠️ Toast functions missing, reinitializing...');
45+ toastInitialized = false;
46+ window.__dumber_toast_loaded = false;
47+ }
48+
49+ console.log('🚀 Initializing Svelte toast system at:', document.readyState);
50+ console.log('📊 DOM state check - head:', !!document.head, 'body:', !!document.body);
51+ window.__dumber_toast_loaded = true;
52+
53+ // Ensure DOM is ready
54+ if (document.readyState === 'loading') {
55+ return new Promise((resolve) => {
56+ document.addEventListener('DOMContentLoaded', async () => {
57+ await initializeToast(config);
58+ resolve();
59+ });
60+ });
61+ }
62+
63+ if (!document.head || !document.body) {
64+ console.warn('❌ DOM structure not ready, deferring toast initialization. Retrying...');
65+ window.__dumber_toast_loaded = false; // Reset the flag so we can try again
66+ return new Promise((resolve) => {
67+ const checkDOM = () => {
68+ if (document.head && document.body) {
69+ console.log('✅ DOM structure now ready, initializing toast system');
70+ initializeToast(config).then(() => resolve());
71+ } else {
72+ console.log('⏳ DOM still not ready, retrying in 50ms...');
73+ setTimeout(checkDOM, 50);
74+ }
75+ };
76+ setTimeout(checkDOM, 100);
77+ });
78+ }
79+
80+ try {
81+ // Find or create browser component root
82+ let rootElement = document.querySelector('.browser-component-root') as HTMLDivElement;
83+ if (!rootElement) {
84+ console.log('🔧 Creating browser-component-root');
85+ rootElement = document.createElement('div');
86+ rootElement.className = 'browser-component-root';
87+ rootElement.style.cssText = 'position: fixed; top: 0; left: 0; pointer-events: none; z-index: 2147483647;';
88+ document.documentElement.appendChild(rootElement);
89+ } else {
90+ console.log('✅ Found existing browser-component-root');
91+ }
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 (Svelte 5 syntax)
100+ mount(ToastContainer, {
101+ target: rootElement
102+ });
103+
104+ // Force immediate effect execution to ensure onMount callbacks run
105+ flushSync();
106+
107+ // Toast functions are now exposed by the component itself
108+ console.log('✅ Toast functions exposed via component instantiation');
109+
110+ toastInitialized = true;
111+ console.log('✅ Toast system initialized with Svelte 5');
112+
113+ } catch (error) {
114+ console.error('❌ Failed to initialize toast system:', error);
115+
116+ // Fallback to basic toast system if Svelte fails
117+ initializeFallbackToast();
118+ }
119+}
120+
121+function exposeToastFunctions(): void {
122+ // The Svelte ToastContainer component already exposes these functions
123+ // We just need to make sure they exist, they're handled by the component
124+ console.log('✅ Toast functions will be exposed by ToastContainer component');
125+}
126+
127+function initializeFallbackToast(): void {
128+ console.warn('🔄 Falling back to basic toast system');
129+
130+ // Create basic toast container for fallback
131+ const fallbackContainer = document.createElement('div');
132+ fallbackContainer.id = 'dumber-fallback-toast';
133+ fallbackContainer.style.cssText = `
134+ position: fixed;
135+ top: 20px;
136+ right: 20px;
137+ z-index: 10000;
138+ pointer-events: none;
139+ `;
140+ document.documentElement.appendChild(fallbackContainer);
141+
142+ // Expose toast functions for fallback too
143+ exposeToastFunctions();
144+
145+ toastInitialized = true;
146+ console.log('✅ Fallback toast system ready');
147+}⏎̸
A gui/src/lib/keyboard/KeyboardService.ts +446 -0
@@ -0,0 +1,446 @@
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+import { COMMON_SHORTCUTS } from './types';
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<KeyboardEventType, Set<(event: KeyboardEvent) => void>>();
23+ private debug: boolean = false;
24+
25+ constructor(config: KeyboardServiceConfig = {}) {
26+ this.debug = config.debug ?? false;
27+ this.globalShortcuts = config.globalShortcuts ?? [];
28+
29+ this.log('info', 'KeyboardService initialized', { config });
30+ }
31+
32+ /**
33+ * Register shortcuts for a component
34+ */
35+ registerShortcuts(componentId: string, shortcuts: ShortcutConfig[]): void {
36+ if (this.components.has(componentId)) {
37+ this.log('warn', `Component ${componentId} already registered, overwriting shortcuts`);
38+ }
39+
40+ const componentShortcuts: ComponentShortcuts = {
41+ componentId,
42+ shortcuts: shortcuts.map(shortcut => ({
43+ ...shortcut,
44+ preventDefault: shortcut.preventDefault ?? true,
45+ stopPropagation: shortcut.stopPropagation ?? true,
46+ whenFocused: shortcut.whenFocused ?? false
47+ })),
48+ focused: false
49+ };
50+
51+ this.components.set(componentId, componentShortcuts);
52+
53+ this.log('info', 'Shortcuts registered', { componentId, shortcuts: shortcuts.length });
54+ this.emit('shortcut-registered', {
55+ shortcut: componentId,
56+ timestamp: Date.now(),
57+ targetComponent: componentId
58+ });
59+ }
60+
61+ /**
62+ * Unregister shortcuts for a component
63+ */
64+ unregisterShortcuts(componentId: string): void {
65+ const component = this.components.get(componentId);
66+ if (!component) {
67+ this.log('warn', `Component ${componentId} not found for unregistration`);
68+ return;
69+ }
70+
71+ // Call cleanup function if provided
72+ if (component.cleanup) {
73+ component.cleanup();
74+ }
75+
76+ this.components.delete(componentId);
77+
78+ // Clear active component if it was this one
79+ if (this.activeComponent === componentId) {
80+ this.activeComponent = null;
81+ }
82+
83+ this.log('info', 'Shortcuts unregistered', { componentId });
84+ this.emit('shortcut-unregistered', {
85+ shortcut: componentId,
86+ timestamp: Date.now(),
87+ targetComponent: componentId
88+ });
89+ }
90+
91+ /**
92+ * Set which component is currently focused
93+ */
94+ setActiveComponent(componentId: string | null): void {
95+ // Blur previous component
96+ if (this.activeComponent && this.components.has(this.activeComponent)) {
97+ const prevComponent = this.components.get(this.activeComponent)!;
98+ prevComponent.focused = false;
99+ this.emit('component-blurred', {
100+ shortcut: this.activeComponent,
101+ timestamp: Date.now(),
102+ targetComponent: this.activeComponent
103+ });
104+ }
105+
106+ this.activeComponent = componentId;
107+
108+ // Focus new component
109+ if (componentId && this.components.has(componentId)) {
110+ const newComponent = this.components.get(componentId)!;
111+ newComponent.focused = true;
112+ this.emit('component-focused', {
113+ shortcut: componentId,
114+ timestamp: Date.now(),
115+ targetComponent: componentId
116+ });
117+ }
118+
119+ this.log('info', 'Active component changed', { from: this.activeComponent, to: componentId });
120+ }
121+
122+ /**
123+ * Handle a keyboard shortcut from native GTK layer
124+ */
125+ handleNativeShortcut(shortcut: string): boolean {
126+ this.log('info', 'Native shortcut received', { shortcut });
127+
128+ // Normalize shortcut string
129+ const normalizedShortcut = this.normalizeShortcut(shortcut);
130+
131+ // Try to handle with focused component first
132+ if (this.activeComponent) {
133+ const handled = this.handleComponentShortcut(this.activeComponent, normalizedShortcut, true);
134+ if (handled) {
135+ return true;
136+ }
137+ }
138+
139+ // Try global shortcuts
140+ const globalHandled = this.handleGlobalShortcuts(normalizedShortcut);
141+ if (globalHandled) {
142+ return true;
143+ }
144+
145+ // Try all components (for non-focused shortcuts)
146+ for (const [componentId] of this.components) {
147+ if (componentId === this.activeComponent) continue; // Already tried above
148+
149+ const handled = this.handleComponentShortcut(componentId, normalizedShortcut, false);
150+ if (handled) {
151+ return true;
152+ }
153+ }
154+
155+ this.log('info', 'Shortcut not handled', { shortcut: normalizedShortcut });
156+ return false;
157+ }
158+
159+ /**
160+ * Handle JavaScript keyboard events (for web page events)
161+ */
162+ handleKeyboardEvent(event: globalThis.KeyboardEvent): boolean {
163+ const shortcut = this.eventToShortcut(event);
164+ if (!shortcut) return false;
165+
166+ this.log('info', 'Keyboard event received', { shortcut });
167+ return this.handleNativeShortcut(shortcut);
168+ }
169+
170+ /**
171+ * Handle JavaScript mouse events (for navigation buttons)
172+ */
173+ handleMouseEvent(event: globalThis.MouseEvent): boolean {
174+ try {
175+ // Mouse button 3 (back button)
176+ if (event.button === 3) {
177+ event.preventDefault();
178+ event.stopPropagation();
179+
180+ if (window.history.length > 1) {
181+ console.log('⬅️ Mouse back button pressed');
182+ window.history.back();
183+ }
184+ return true;
185+ }
186+
187+ // Mouse button 4 (forward button)
188+ if (event.button === 4) {
189+ event.preventDefault();
190+ event.stopPropagation();
191+
192+ console.log('➡️ Mouse forward button pressed');
193+ window.history.forward();
194+ return true;
195+ }
196+
197+ } catch (error) {
198+ this.log('error', 'Mouse handler error', { error });
199+ }
200+
201+ return false;
202+ }
203+
204+ /**
205+ * Add event listener for keyboard service events
206+ */
207+ addEventListener(type: KeyboardEventType, listener: (event: KeyboardEvent) => void): void {
208+ if (!this.eventListeners.has(type)) {
209+ this.eventListeners.set(type, new Set());
210+ }
211+ this.eventListeners.get(type)!.add(listener);
212+ }
213+
214+ /**
215+ * Remove event listener
216+ */
217+ removeEventListener(type: KeyboardEventType, listener: (event: KeyboardEvent) => void): void {
218+ const listeners = this.eventListeners.get(type);
219+ if (listeners) {
220+ listeners.delete(listener);
221+ }
222+ }
223+
224+ /**
225+ * Get all registered shortcuts for debugging
226+ */
227+ getDebugInfo(): object {
228+ return {
229+ components: Array.from(this.components.entries()).map(([id, comp]) => ({
230+ id,
231+ shortcuts: comp.shortcuts.length,
232+ focused: comp.focused
233+ })),
234+ activeComponent: this.activeComponent,
235+ globalShortcuts: this.globalShortcuts.length
236+ };
237+ }
238+
239+ // Private methods
240+
241+ private handleComponentShortcut(componentId: string, shortcut: string, respectFocus: boolean): boolean {
242+ const component = this.components.get(componentId);
243+ if (!component) return false;
244+
245+ for (const shortcutConfig of component.shortcuts) {
246+ if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
247+ // Check focus requirement
248+ if (respectFocus && shortcutConfig.whenFocused && !component.focused) {
249+ continue;
250+ }
251+
252+ try {
253+ shortcutConfig.handler();
254+ this.log('info', 'Shortcut handled', {
255+ shortcut,
256+ componentId,
257+ focused: component.focused,
258+ description: shortcutConfig.description
259+ });
260+
261+ this.emit('shortcut-triggered', {
262+ shortcut,
263+ timestamp: Date.now(),
264+ targetComponent: componentId
265+ });
266+
267+ return true;
268+ } catch (error) {
269+ this.log('error', 'Shortcut handler error', { shortcut, componentId, error });
270+ }
271+ }
272+ }
273+
274+ return false;
275+ }
276+
277+ private handleGlobalShortcuts(shortcut: string): boolean {
278+ for (const shortcutConfig of this.globalShortcuts) {
279+ if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
280+ try {
281+ shortcutConfig.handler();
282+ this.log('info', 'Global shortcut handled', { shortcut, description: shortcutConfig.description });
283+
284+ this.emit('shortcut-triggered', {
285+ shortcut,
286+ timestamp: Date.now()
287+ });
288+
289+ return true;
290+ } catch (error) {
291+ this.log('error', 'Global shortcut handler error', { shortcut, error });
292+ }
293+ }
294+ }
295+ return false;
296+ }
297+
298+ private normalizeShortcut(shortcut: string): string {
299+ return shortcut.toLowerCase().replace(/\s+/g, '');
300+ }
301+
302+ private eventToShortcut(event: globalThis.KeyboardEvent): string | null {
303+ const parts: string[] = [];
304+
305+ // Add modifiers using MODIFIER_KEYS mapping
306+ if (event.ctrlKey && event.metaKey) {
307+ // Both ctrl and meta pressed - use cmdorctrl
308+ parts.push('cmdorctrl');
309+ } else if (event.ctrlKey) {
310+ parts.push('ctrl');
311+ } else if (event.metaKey) {
312+ parts.push('cmd');
313+ }
314+
315+ if (event.altKey) parts.push('alt');
316+ if (event.shiftKey) parts.push('shift');
317+
318+ // Add key
319+ if (event.key) {
320+ parts.push(event.key.toLowerCase());
321+ }
322+
323+ return parts.length > 0 ? parts.join('+') : null;
324+ }
325+
326+ private emit(type: KeyboardEventType, detail: KeyboardEventDetail): void {
327+ const listeners = this.eventListeners.get(type);
328+ if (listeners) {
329+ const keyboardEvent: KeyboardEvent = { type, detail };
330+ listeners.forEach(listener => {
331+ try {
332+ listener(keyboardEvent);
333+ } catch (error) {
334+ this.log('error', 'Event listener error', { type, error });
335+ }
336+ });
337+ }
338+ }
339+
340+ private log(level: 'info' | 'warn' | 'error' | string, message?: string, data?: unknown): void {
341+ if (!this.debug && level !== 'error') return;
342+
343+ const logMessage = message ? `[KeyboardService] ${message}` : `[KeyboardService] ${level}`;
344+
345+ switch (level) {
346+ case 'error':
347+ console.error(logMessage, data);
348+ break;
349+ case 'warn':
350+ console.warn(logMessage, data);
351+ break;
352+ default:
353+ console.log(logMessage, data);
354+ }
355+ }
356+}
357+
358+// Create and export singleton instance with global shortcuts
359+export const keyboardService = new KeyboardService({
360+ debug: false, // Disable debug logging in production
361+ globalShortcuts: [
362+ // Navigation shortcuts (migrated from controls module)
363+ {
364+ key: 'alt+arrowleft',
365+ handler: () => {
366+ if (window.history.length > 1) {
367+ window.history.back();
368+ }
369+ },
370+ description: 'Navigate back (Alt+Left Arrow)'
371+ },
372+ {
373+ key: 'alt+arrowright',
374+ handler: () => {
375+ window.history.forward();
376+ },
377+ description: 'Navigate forward (Alt+Right Arrow)'
378+ },
379+ // Global UI shortcuts (migrated from UCM JavaScript)
380+ {
381+ key: COMMON_SHORTCUTS.OMNIBOX_OPEN,
382+ handler: () => {
383+ try {
384+ if (window.__dumber_omnibox?.toggle) {
385+ console.log('🎯 Using omnibox API directly');
386+ window.__dumber_omnibox.toggle();
387+ } else if (typeof window.__dumber_toggle === 'function') {
388+ console.log('🎯 Using legacy toggle function');
389+ window.__dumber_toggle();
390+ } else {
391+ console.warn('⚠️ No omnibox toggle function available');
392+ }
393+ } catch (error) {
394+ console.error('❌ Error in omnibox open handler:', error);
395+ }
396+ },
397+ description: 'Open omnibox (Ctrl/Cmd+L)'
398+ },
399+ {
400+ key: COMMON_SHORTCUTS.FIND_OPEN,
401+ handler: () => {
402+ try {
403+ if (window.__dumber_omnibox?.open) {
404+ console.log('🔍 Using omnibox find API directly');
405+ window.__dumber_omnibox.open('find');
406+ } else if (typeof window.__dumber_find_open === 'function') {
407+ console.log('🔍 Using legacy find function');
408+ window.__dumber_find_open();
409+ } else {
410+ console.warn('⚠️ No find function available');
411+ }
412+ } catch (error) {
413+ console.error('❌ Error in find open handler:', error);
414+ }
415+ },
416+ description: 'Open find bar (Ctrl/Cmd+F)'
417+ },
418+ // Copy URL shortcut (Ctrl/Cmd+Shift+C)
419+ {
420+ key: 'cmdorctrl+shift+c',
421+ handler: async () => {
422+ try {
423+ const url = window.location.href;
424+ await navigator.clipboard.writeText(url);
425+
426+ // Show toast notification
427+ if (typeof window.__dumber_showToast === 'function') {
428+ window.__dumber_showToast('URL copied to clipboard!', 2000, 'success');
429+ }
430+ } catch (error) {
431+ console.error('Failed to copy URL:', error);
432+
433+ // Show error toast
434+ if (typeof window.__dumber_showToast === 'function') {
435+ window.__dumber_showToast('Failed to copy URL', 2000, 'error');
436+ }
437+ }
438+ },
439+ description: 'Copy current URL to clipboard (Ctrl/Cmd+Shift+C)'
440+ }
441+ ]
442+});
443+
444+// Export types for use in components
445+export type { ShortcutConfig, ComponentShortcuts, KeyboardServiceConfig } from './types';
446+export { COMMON_SHORTCUTS } from './types';⏎̸
A gui/src/lib/keyboard/index.ts +17 -0
@@ -0,0 +1,17 @@
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';⏎̸
A gui/src/lib/keyboard/types.ts +101 -0
@@ -0,0 +1,101 @@
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., 'cmdorctrl+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+ cmd: 'metaKey',
82+ cmdorctrl: navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey',
83+ alt: 'altKey',
84+ shift: 'shiftKey'
85+} as const;
86+
87+/**
88+ * Common shortcut patterns used throughout the application
89+ */
90+export const COMMON_SHORTCUTS = {
91+ OMNIBOX_OPEN: 'cmdorctrl+l',
92+ FIND_OPEN: 'cmdorctrl+f',
93+ ESCAPE: 'escape',
94+ ENTER: 'enter',
95+ ARROW_UP: 'arrowup',
96+ ARROW_DOWN: 'arrowdown',
97+ ARROW_LEFT: 'arrowleft',
98+ ARROW_RIGHT: 'arrowright',
99+ TAB: 'tab',
100+ SHIFT_TAB: 'shift+tab'
101+} as const;⏎̸
A gui/src/lib/styles.css +1 -0
@@ -0,0 +1,1 @@
1+@import '../styles/tailwind.css';⏎̸
A gui/src/lib/types.ts +44 -0
@@ -0,0 +1,44 @@
1+// Global window API types for browser integration
2+declare global {
3+ interface Window {
4+ // Toast system API
5+ __dumber_toast_loaded?: boolean;
6+ __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void;
7+ __dumber_dismissToast?: (id: number) => void;
8+ __dumber_clearToasts?: () => void;
9+ __dumber_showZoomToast?: (zoomLevel: number) => void;
10+
11+ // Theme integration
12+ __dumber_initial_theme?: string;
13+ __dumber_setTheme?: (theme: 'light' | 'dark') => void;
14+
15+ // WebKit message handler
16+ webkit?: {
17+ messageHandlers?: {
18+ dumber?: {
19+ postMessage: (message: string) => void;
20+ };
21+ };
22+ };
23+ }
24+}
25+
26+export interface ToastAPI {
27+ show: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number;
28+ dismiss: (id: number) => void;
29+ clear: () => void;
30+ showZoom: (zoomLevel: number) => void;
31+}
32+
33+export interface ThemeAPI {
34+ setTheme: (theme: 'light' | 'dark') => void;
35+ getCurrentTheme: () => 'light' | 'dark';
36+}
37+
38+// Message types for WebKit communication
39+export interface BrowserMessage {
40+ action: string;
41+ data?: unknown;
42+}
43+
44+export {}; // Make this file a module⏎̸
A gui/src/main.ts +73 -0
@@ -0,0 +1,73 @@
1+import { AppService } from './services/app.js';
2+import { BrowserControlsService } from './services/browser-controls.js';
3+import { DOMRenderer } from './utils/dom.js';
4+
5+class DumberBrowserApp {
6+ private appService: AppService;
7+ private browserControls: BrowserControlsService;
8+ private domRenderer: DOMRenderer;
9+
10+ constructor() {
11+ this.appService = new AppService();
12+ this.browserControls = new BrowserControlsService();
13+ this.domRenderer = new DOMRenderer();
14+ }
15+
16+ async initialize(): Promise<void> {
17+ try {
18+ // Show loading states
19+ this.domRenderer.showLoading('history');
20+ this.domRenderer.showLoading('shortcuts');
21+
22+ // Initialize services
23+ await this.appService.initialize();
24+
25+ // Initialize browser controls
26+ await this.browserControls.initialize();
27+
28+ // Render the UI
29+ this.render();
30+
31+ console.log('✓ Dumber Browser initialized successfully');
32+ } catch (error) {
33+ console.error('Failed to initialize Dumber Browser:', error);
34+ this.handleInitializationError();
35+ }
36+ }
37+
38+ private render(): void {
39+ try {
40+ // Render history
41+ const history = this.appService.getHistory();
42+ this.domRenderer.displayHistory(history);
43+
44+ // Render shortcuts
45+ const shortcuts = this.appService.getShortcuts();
46+ this.domRenderer.displayShortcuts(shortcuts);
47+ } catch (error) {
48+ console.error('Failed to render UI:', error);
49+ this.domRenderer.showError('history', 'Failed to load history');
50+ this.domRenderer.showError('shortcuts', 'Failed to load shortcuts');
51+ }
52+ }
53+
54+ private handleInitializationError(): void {
55+ this.domRenderer.showError('history', 'Failed to initialize application');
56+ this.domRenderer.showError('shortcuts', 'Failed to initialize application');
57+ }
58+}
59+
60+// Initialize the application when the DOM is ready
61+function initializeApp(): void {
62+ const app = new DumberBrowserApp();
63+ app.initialize().catch(error => {
64+ console.error('Critical error during app initialization:', error);
65+ });
66+}
67+
68+// Wait for DOM to be ready
69+if (document.readyState === 'loading') {
70+ document.addEventListener('DOMContentLoaded', initializeApp);
71+} else {
72+ initializeApp();
73+}⏎̸
A gui/src/pages/Homepage.svelte +407 -0
@@ -0,0 +1,407 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+
4+ interface HistoryItem {
5+ id: number;
6+ url: string;
7+ title: string;
8+ visit_count: number;
9+ last_visited: string;
10+ created_at: string;
11+ }
12+
13+ interface Shortcut {
14+ key: string;
15+ description: string;
16+ action: () => void;
17+ }
18+
19+ let historyItems = $state<HistoryItem[]>([]);
20+ let shortcuts = $state<Shortcut[]>([]);
21+ let historyLoading = $state(true);
22+ let shortcutsLoading = $state(true);
23+
24+ // Initialize shortcuts (these are browser shortcuts, not dynamic from API)
25+ const initializeShortcuts = () => {
26+ shortcuts = [
27+ {
28+ key: 'Ctrl+L',
29+ description: 'Focus address bar',
30+ action: () => {
31+ // This will be handled by the browser's keyboard shortcuts
32+ console.log('Ctrl+L triggered');
33+ }
34+ },
35+ {
36+ key: 'Ctrl+F',
37+ description: 'Find in page',
38+ action: () => {
39+ // This will be handled by the browser's keyboard shortcuts
40+ console.log('Ctrl+F triggered');
41+ }
42+ },
43+ {
44+ key: 'Ctrl+R',
45+ description: 'Reload page',
46+ action: () => {
47+ window.location.reload();
48+ }
49+ },
50+ {
51+ key: 'Ctrl+0',
52+ description: 'Reset zoom',
53+ action: () => {
54+ // This will be handled by the browser's zoom controls
55+ console.log('Ctrl+0 triggered');
56+ }
57+ },
58+ {
59+ key: 'Ctrl++',
60+ description: 'Zoom in',
61+ action: () => {
62+ // This will be handled by the browser's zoom controls
63+ console.log('Ctrl++ triggered');
64+ }
65+ },
66+ {
67+ key: 'Ctrl+-',
68+ description: 'Zoom out',
69+ action: () => {
70+ // This will be handled by the browser's zoom controls
71+ console.log('Ctrl+- triggered');
72+ }
73+ }
74+ ];
75+ shortcutsLoading = false;
76+ };
77+
78+ // Fetch recent history from the API
79+ const fetchHistory = async () => {
80+ try {
81+ const response = await fetch('dumb://homepage/api/history/recent?limit=20');
82+ if (response.ok) {
83+ const data = await response.json();
84+ historyItems = Array.isArray(data) ? data : [];
85+ } else {
86+ console.warn('Failed to fetch history:', response.status);
87+ historyItems = [];
88+ }
89+ } catch (error) {
90+ console.error('Error fetching history:', error);
91+ historyItems = [];
92+ } finally {
93+ historyLoading = false;
94+ }
95+ };
96+
97+ // Navigate to a URL when history item is clicked
98+ const navigateTo = (url: string) => {
99+ window.location.href = url;
100+ };
101+
102+ // Get domain from URL
103+ const getDomain = (url: string): string => {
104+ try {
105+ return new URL(url).hostname;
106+ } catch {
107+ return url;
108+ }
109+ };
110+
111+ // Format relative time
112+ const formatTime = (timestamp: string): string => {
113+ try {
114+ const date = new Date(timestamp);
115+ const now = new Date();
116+ const diff = now.getTime() - date.getTime();
117+
118+ const minutes = Math.floor(diff / (1000 * 60));
119+ const hours = Math.floor(diff / (1000 * 60 * 60));
120+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
121+
122+ if (minutes < 1) return 'Just now';
123+ if (minutes < 60) return `${minutes}m ago`;
124+ if (hours < 24) return `${hours}h ago`;
125+ if (days < 7) return `${days}d ago`;
126+
127+ return date.toLocaleDateString();
128+ } catch {
129+ return '';
130+ }
131+ };
132+
133+ onMount(() => {
134+ initializeShortcuts();
135+ fetchHistory();
136+ });
137+
138+ // Combined loading state
139+ const loading = $derived(historyLoading || shortcutsLoading);
140+</script>
141+
142+<div class="container">
143+ <div class="history-section">
144+ <h2 class="section-title">Recent History</h2>
145+ <div class="history-list">
146+ {#if historyLoading}
147+ <div class="loading">Loading history...</div>
148+ {:else if historyItems.length === 0}
149+ <div class="empty-state">
150+ <h3>No history yet</h3>
151+ <p>Start browsing to see your recent history here.</p>
152+ </div>
153+ {:else}
154+ {#each historyItems as item (item.url)}
155+ <div
156+ class="history-item"
157+ role="button"
158+ tabindex="0"
159+ onclick={() => navigateTo(item.url)}
160+ onkeydown={(e) => e.key === 'Enter' && navigateTo(item.url)}
161+ >
162+ <div class="history-line">
163+ <div class="history-favicon-chip">
164+ <div class="history-favicon-fallback">🌐</div>
165+ </div>
166+ <div class="history-title">{item.title || 'Untitled'}</div>
167+ <div class="history-sep">•</div>
168+ <div class="history-domain">{getDomain(item.url)}</div>
169+ <div class="history-sep">•</div>
170+ <div class="history-url">{item.url}</div>
171+ <div class="history-time">{formatTime(item.last_visited)}</div>
172+ </div>
173+ </div>
174+ {/each}
175+ {/if}
176+ </div>
177+ </div>
178+
179+ <div class="shortcuts-section">
180+ <h2 class="section-title">Keyboard Shortcuts</h2>
181+ <div class="shortcuts-grid">
182+ {#if shortcutsLoading}
183+ <div class="loading">Loading shortcuts...</div>
184+ {:else}
185+ {#each shortcuts as shortcut (shortcut.key)}
186+ <div
187+ class="shortcut"
188+ role="button"
189+ tabindex="0"
190+ onclick={shortcut.action}
191+ onkeydown={(e) => e.key === 'Enter' && shortcut.action()}
192+ >
193+ <div class="shortcut-key">{shortcut.key}</div>
194+ <div class="shortcut-desc">{shortcut.description}</div>
195+ </div>
196+ {/each}
197+ {/if}
198+ </div>
199+ </div>
200+</div>
201+
202+<style>
203+ /* Global styles - using CSS to match the original design */
204+ :global(*) {
205+ margin: 0;
206+ padding: 0;
207+ box-sizing: border-box;
208+ }
209+
210+ :global(body) {
211+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
212+ background: #1a1a1a;
213+ color: #ffffff;
214+ height: 100vh;
215+ display: flex;
216+ flex-direction: column;
217+ overflow-x: hidden;
218+ }
219+
220+ .container {
221+ flex: 1;
222+ display: flex;
223+ max-width: 1200px;
224+ margin: 0 auto;
225+ width: 100%;
226+ padding: 2rem;
227+ gap: 2rem;
228+ overflow-x: hidden;
229+ }
230+
231+ .history-section {
232+ flex: 1;
233+ min-height: 0;
234+ min-width: 0;
235+ }
236+
237+ .shortcuts-section {
238+ flex: 1;
239+ min-height: 0;
240+ min-width: 0;
241+ }
242+
243+ .section-title {
244+ font-size: 1.5rem;
245+ margin-bottom: 1rem;
246+ color: #ffffff;
247+ border-bottom: 2px solid #404040;
248+ padding-bottom: 0.5rem;
249+ }
250+
251+ .history-list {
252+ overflow-y: auto;
253+ overflow-x: hidden;
254+ max-height: calc(100vh - 8rem);
255+ max-width: 100%;
256+ }
257+
258+ .history-item {
259+ padding: 0.75rem;
260+ margin-bottom: 0.5rem;
261+ background: #2d2d2d;
262+ border-radius: 6px;
263+ cursor: pointer;
264+ transition: background 0.2s;
265+ border-left: 3px solid #404040;
266+ overflow: hidden;
267+ max-width: 100%;
268+ }
269+
270+ .history-item:hover {
271+ background: #3d3d3d;
272+ border-left-color: #0066cc;
273+ }
274+
275+ .history-line {
276+ display: flex;
277+ gap: 0.5rem;
278+ white-space: nowrap;
279+ align-items: center;
280+ overflow: hidden;
281+ width: 100%;
282+ min-width: 0;
283+ }
284+
285+ .history-favicon-chip {
286+ flex: 0 0 20px;
287+ width: 20px;
288+ height: 20px;
289+ border-radius: 50%;
290+ background: #ccc;
291+ border: 1px solid rgba(0,0,0,.12);
292+ box-shadow: 0 1px 2px rgba(0,0,0,.12);
293+ display: flex;
294+ align-items: center;
295+ justify-content: center;
296+ }
297+
298+ .history-favicon-img {
299+ width: 16px;
300+ height: 16px;
301+ filter: brightness(1.06) contrast(1.03);
302+ image-rendering: -webkit-optimize-contrast;
303+ }
304+
305+ .history-favicon-fallback {
306+ font-size: 12px;
307+ }
308+
309+ .history-title {
310+ font-size: 0.95rem;
311+ color: #e6e6e6;
312+ flex: 0 0 auto;
313+ }
314+
315+ .history-domain {
316+ font-size: 0.9rem;
317+ color: #a5a5a5;
318+ flex: 0 0 auto;
319+ }
320+
321+ .history-sep {
322+ color: #666;
323+ flex: 0 0 auto;
324+ }
325+
326+ .history-url {
327+ font-size: 0.9rem;
328+ color: #7a7a7a;
329+ flex: 1 1 auto;
330+ min-width: 0;
331+ overflow: hidden;
332+ text-overflow: ellipsis;
333+ -webkit-mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%);
334+ mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%);
335+ }
336+
337+ .history-time {
338+ font-size: 0.85rem;
339+ color: #666;
340+ flex: 0 0 auto;
341+ margin-left: auto;
342+ }
343+
344+ .shortcuts-grid {
345+ display: grid;
346+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
347+ gap: 1rem;
348+ max-height: calc(100vh - 8rem);
349+ overflow-y: auto;
350+ }
351+
352+ .shortcut {
353+ padding: 1rem;
354+ background: #2d2d2d;
355+ border: 2px solid #404040;
356+ border-radius: 8px;
357+ cursor: pointer;
358+ transition: all 0.2s;
359+ text-align: center;
360+ }
361+
362+ .shortcut:hover {
363+ background: #3d3d3d;
364+ border-color: #0066cc;
365+ transform: translateY(-2px);
366+ }
367+
368+ .shortcut-key {
369+ font-weight: bold;
370+ color: #0066cc;
371+ margin-bottom: 0.5rem;
372+ font-size: 1.1rem;
373+ }
374+
375+ .shortcut-desc {
376+ color: #888;
377+ font-size: 0.9rem;
378+ }
379+
380+ .loading {
381+ padding: 2rem;
382+ text-align: center;
383+ color: #888;
384+ }
385+
386+ .empty-state {
387+ padding: 3rem 2rem;
388+ text-align: center;
389+ color: #666;
390+ }
391+
392+ .empty-state h3 {
393+ margin-bottom: 1rem;
394+ color: #888;
395+ }
396+
397+ @media (max-width: 768px) {
398+ .container {
399+ flex-direction: column;
400+ padding: 1rem;
401+ }
402+
403+ .shortcuts-grid {
404+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
405+ }
406+ }
407+</style>⏎̸
A gui/src/pages/homepage.ts +9 -0
@@ -0,0 +1,9 @@
1+import { mount } from 'svelte';
2+import Homepage from './Homepage.svelte';
3+
4+// Mount the Homepage component to the DOM
5+const app = mount(Homepage, {
6+ target: document.body,
7+});
8+
9+export default app;⏎̸
A gui/src/services/app.ts +105 -0
@@ -0,0 +1,105 @@
1+type HistoryEntry = {
2+ id: number;
3+ url: string;
4+ title: string;
5+ visit_count: number;
6+ last_visited: string | null;
7+ created_at: string | null;
8+}
9+
10+export class AppService {
11+ private history: HistoryEntry[] = [];
12+ private shortcuts: Record<string, any> = {};
13+
14+ constructor() {}
15+
16+ async initialize(): Promise<void> {
17+ try {
18+ await Promise.all([
19+ this.loadHistory(),
20+ this.loadShortcuts()
21+ ]);
22+ } catch (error) {
23+ console.error('Failed to initialize app:', error);
24+ }
25+ }
26+
27+ async loadHistory(): Promise<void> {
28+ try {
29+ const recentHistory: HistoryEntry[] = await fetch('/api/history/recent?limit=50').then(r => r.json());
30+ this.history = recentHistory;
31+ } catch (error) {
32+ console.error('Failed to load history:', error);
33+ // Mock history for development/fallback
34+ this.history = [
35+ {
36+ id: 1,
37+ url: 'https://github.com/wailsapp/wails',
38+ title: 'Wails Framework',
39+ visit_count: 3,
40+ last_visited: new Date().toISOString(),
41+ created_at: new Date().toISOString()
42+ },
43+ {
44+ id: 2,
45+ url: 'https://go.dev',
46+ title: 'The Go Programming Language',
47+ visit_count: 2,
48+ last_visited: new Date().toISOString(),
49+ created_at: new Date().toISOString()
50+ },
51+ {
52+ id: 3,
53+ url: 'https://developer.mozilla.org',
54+ title: 'MDN Web Docs',
55+ visit_count: 1,
56+ last_visited: new Date().toISOString(),
57+ created_at: new Date().toISOString()
58+ }
59+ ];
60+ }
61+ }
62+
63+ async loadShortcuts(): Promise<void> {
64+ try {
65+ const cfg = await fetch('/api/config').then(r => r.json());
66+ const raw = cfg?.search_shortcuts || {};
67+ // Normalize field casing from backend (supports URL/Description and url/description)
68+ const normalized: Record<string, { description: string; url: string }> = {};
69+ for (const [key, value] of Object.entries(raw)) {
70+ const v: any = value as any;
71+ normalized[key] = {
72+ description: v.description ?? v.Description ?? '',
73+ url: v.url ?? v.URL ?? ''
74+ };
75+ }
76+ this.shortcuts = normalized;
77+ } catch (error) {
78+ console.error('Failed to load shortcuts:', error);
79+ // Mock shortcuts for development/fallback
80+ this.shortcuts = {
81+ 'g': { description: 'Google Search', url: 'https://google.com/search?q={query}' },
82+ 'gh': { description: 'GitHub Search', url: 'https://github.com/search?q={query}' },
83+ 'so': { description: 'Stack Overflow', url: 'https://stackoverflow.com/search?q={query}' },
84+ 'w': { description: 'Wikipedia', url: 'https://en.wikipedia.org/wiki/Special:Search?search={query}' }
85+ };
86+ }
87+ }
88+
89+ getHistory(): HistoryEntry[] {
90+ return this.history;
91+ }
92+
93+ getShortcuts(): Record<string, any> {
94+ return this.shortcuts;
95+ }
96+
97+ async copyToClipboard(text: string): Promise<void> {
98+ try {
99+ await navigator.clipboard.writeText(text);
100+ console.log('Copied to clipboard:', text);
101+ } catch (err) {
102+ console.error('Failed to copy:', err);
103+ }
104+ }
105+}
A gui/src/services/browser-controls.ts +179 -0
@@ -0,0 +1,179 @@
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('info', 'frontend', 'init', 'BrowserControlsService initializing...');
12+ this.debugConsole.addLog('info', 'frontend', 'init', 'Running without Wails bindings');
13+
14+ this.initializeEventListeners();
15+ this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initialized ✓');
16+ }
17+
18+
19+ private initializeEventListeners(): void {
20+ // Keyboard event handler for browser controls
21+ document.addEventListener('keydown', this.handleKeyboardEvent.bind(this));
22+
23+ // Mouse button handler for navigation
24+ document.addEventListener('mousedown', this.handleMouseEvent.bind(this));
25+
26+ // URL change listener (for SPA navigation)
27+ window.addEventListener('popstate', this.handleURLChange.bind(this));
28+ }
29+
30+ private async handleKeyboardEvent(event: KeyboardEvent): Promise<void> {
31+ // Skip F12 (debug console toggle)
32+ if (event.key === 'F12') {
33+ return;
34+ }
35+
36+ // Log all keyboard events to debug console
37+ this.debugConsole.logKeyboardEvent(event);
38+
39+ const { ctrlKey, metaKey, shiftKey, altKey, key } = event;
40+ const isCmd = ctrlKey || metaKey;
41+
42+ // Zoom controls handled natively by GTK; don't intercept
43+ if (isCmd && (key === '+' || key === '=' || key === '-' || key === '0')) {
44+ return; // allow native handler to process
45+ }
46+
47+ // Copy URL
48+ if (isCmd && shiftKey && key === 'C') {
49+ this.debugConsole.addLog('info', 'frontend', 'action', 'Copy URL triggered');
50+ event.preventDefault();
51+ await this.copyCurrentURL();
52+ return;
53+ }
54+
55+ // Navigation
56+ if (altKey && key === 'ArrowLeft') {
57+ this.debugConsole.addLog('info', 'frontend', 'action', 'Go Back triggered');
58+ event.preventDefault();
59+ history.back();
60+ return;
61+ }
62+
63+ if (altKey && key === 'ArrowRight') {
64+ this.debugConsole.addLog('info', 'frontend', 'action', 'Go Forward triggered');
65+ event.preventDefault();
66+ history.forward();
67+ return;
68+ }
69+ }
70+
71+ private async handleMouseEvent(event: MouseEvent): Promise<void> {
72+ // Log all mouse events to debug console
73+ this.debugConsole.logMouseEvent(event);
74+
75+ // Mouse button 3 (back button)
76+ if (event.button === 3) {
77+ this.debugConsole.addLog('info', 'frontend', 'action', 'Mouse Back button triggered');
78+ event.preventDefault();
79+ await this.goBack();
80+ return;
81+ }
82+
83+ // Mouse button 4 (forward button)
84+ if (event.button === 4) {
85+ this.debugConsole.addLog('info', 'frontend', 'action', 'Mouse Forward button triggered');
86+ event.preventDefault();
87+ await this.goForward();
88+ return;
89+ }
90+ }
91+
92+ private handleURLChange(): void {
93+ this.setCurrentURL(window.location.href);
94+ this.loadSavedZoom();
95+ }
96+
97+ // Zoom is handled natively; no JS-based zoom adjustments needed.
98+
99+ async copyCurrentURL(): Promise<void> {
100+ try {
101+ const url = this.getCurrentURL();
102+ await navigator.clipboard.writeText(url);
103+
104+ // URL copied successfully - no backend notification needed
105+
106+ this.notifications.show('URL copied to clipboard!', 'success');
107+ } catch (error) {
108+ console.error('Failed to copy URL:', error);
109+ this.notifications.show('Failed to copy URL', 'error');
110+ }
111+ }
112+
113+ async goBack(): Promise<void> {
114+ this.debugConsole.addLog('info', 'frontend', 'service', 'Calling GoBack');
115+
116+ try {
117+ // await BrowserService.GoBack(); // TODO: Implement BrowserService
118+ this.debugConsole.logServiceCall('BrowserService', 'GoBack', []);
119+ history.back();
120+ this.debugConsole.addLog('info', 'frontend', 'navigation', 'Browser navigated back');
121+ } catch (error) {
122+ this.debugConsole.logServiceCall('BrowserService', 'GoBack', [], null, error as Error);
123+ console.error('Failed to go back:', error);
124+ }
125+ }
126+
127+ async goForward(): Promise<void> {
128+ this.debugConsole.addLog('info', 'frontend', 'service', 'Calling GoForward');
129+
130+ try {
131+ // await BrowserService.GoForward(); // TODO: Implement BrowserService
132+ this.debugConsole.logServiceCall('BrowserService', 'GoForward', []);
133+ history.forward();
134+ this.debugConsole.addLog('info', 'frontend', 'navigation', 'Browser navigated forward');
135+ } catch (error) {
136+ this.debugConsole.logServiceCall('BrowserService', 'GoForward', [], null, error as Error);
137+ console.error('Failed to go forward:', error);
138+ }
139+ }
140+
141+ getCurrentURL(): string {
142+ return this.currentURL || window.location.href;
143+ }
144+
145+ setCurrentURL(url: string): void {
146+ this.currentURL = url;
147+ this.updateWindowTitle(url);
148+ }
149+
150+ private async updateWindowTitle(url: string): Promise<void> {
151+ try {
152+ // Extract domain or path for title
153+ let title = 'Dumber Browser';
154+ if (url && url !== '/') {
155+ const urlObj = new URL(url);
156+ title = `Dumber - ${urlObj.hostname}`;
157+ }
158+
159+ document.title = title;
160+
161+ // Backend title updates handled natively; no frontend call
162+ } catch (error) {
163+ console.error('Failed to update title:', error);
164+ }
165+ }
166+
167+ async loadSavedZoom(): Promise<void> {
168+ // Zoom persistence applied natively on page load
169+ }
170+
171+ async initialize(): Promise<void> {
172+ // Check if there's an initial URL from backend (direct navigation)
173+ // Set initial URL from browser location (homepage)
174+ this.setCurrentURL(window.location.href);
175+
176+ // Load saved zoom level (only if we're staying on homepage)
177+ // Zoom handled natively
178+ }
179+}
A gui/src/services/debug-console.ts +258 -0
@@ -0,0 +1,258 @@
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?: any;
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.querySelector('#clear-logs')?.addEventListener('click', () => this.clearLogs());
76+ header.querySelector('#close-debug')?.addEventListener('click', () => this.hide());
77+ }
78+
79+ private setupKeyboardShortcut(): void {
80+ document.addEventListener('keydown', (event) => {
81+ // F12 to toggle debug console
82+ if (event.key === 'F12') {
83+ event.preventDefault();
84+ this.toggle();
85+ }
86+ });
87+ }
88+
89+ private interceptConsole(): void {
90+ const originalConsole = {
91+ log: console.log,
92+ warn: console.warn,
93+ error: console.error,
94+ info: console.info,
95+ debug: console.debug
96+ };
97+
98+ console.log = (...args) => {
99+ this.addLog('info', 'frontend', 'console', args.join(' '));
100+ originalConsole.log(...args);
101+ };
102+
103+ console.warn = (...args) => {
104+ this.addLog('warn', 'frontend', 'console', args.join(' '));
105+ originalConsole.warn(...args);
106+ };
107+
108+ console.error = (...args) => {
109+ this.addLog('error', 'frontend', 'console', args.join(' '));
110+ originalConsole.error(...args);
111+ };
112+
113+ console.info = (...args) => {
114+ this.addLog('info', 'frontend', 'console', args.join(' '));
115+ originalConsole.info(...args);
116+ };
117+
118+ console.debug = (...args) => {
119+ this.addLog('debug', 'frontend', 'console', args.join(' '));
120+ originalConsole.debug(...args);
121+ };
122+ }
123+
124+ public addLog(level: DebugLogEntry['level'], source: DebugLogEntry['source'], category: string, message: string, data?: any): void {
125+ const entry: DebugLogEntry = {
126+ timestamp: new Date().toISOString().split('T')[1]?.split('.')[0] || '', // HH:MM:SS format
127+ level,
128+ source,
129+ category,
130+ message,
131+ data
132+ };
133+
134+ this.logs.push(entry);
135+
136+ // Keep only the last maxLogs entries
137+ if (this.logs.length > this.maxLogs) {
138+ this.logs.shift();
139+ }
140+
141+ this.updateDisplay();
142+ }
143+
144+ private updateDisplay(): void {
145+ if (!this.logsContainer) return;
146+
147+ const logHTML = this.logs.map(log => {
148+ const levelColor = {
149+ info: '#00ff00',
150+ warn: '#ffff00',
151+ error: '#ff0000',
152+ debug: '#00ffff'
153+ }[log.level];
154+
155+ const sourceIcon = log.source === 'backend' ? '🖥️' : '🌐';
156+
157+ return `
158+ <div style="margin-bottom: 2px; font-size: 11px;">
159+ <span style="color: #888;">[${log.timestamp}]</span>
160+ <span style="color: ${levelColor};">${sourceIcon} ${log.level.toUpperCase()}</span>
161+ <span style="color: #aaa;">[${log.category}]</span>
162+ <span style="color: #fff;">${log.message}</span>
163+ ${log.data ? `<pre style="color: #ccc; font-size: 10px; margin: 2px 0;">${JSON.stringify(log.data, null, 2)}</pre>` : ''}
164+ </div>
165+ `;
166+ }).join('');
167+
168+ this.logsContainer.innerHTML = logHTML;
169+
170+ // Auto-scroll to bottom
171+ this.logsContainer.scrollTop = this.logsContainer.scrollHeight;
172+ }
173+
174+ public logKeyboardEvent(event: KeyboardEvent): void {
175+ const modifiers = [];
176+ if (event.ctrlKey) modifiers.push('Ctrl');
177+ if (event.shiftKey) modifiers.push('Shift');
178+ if (event.altKey) modifiers.push('Alt');
179+ if (event.metaKey) modifiers.push('Meta');
180+
181+ const shortcut = modifiers.length > 0 ? `${modifiers.join('+')}+${event.key}` : event.key;
182+
183+ this.addLog('info', 'frontend', 'keyboard', `Key pressed: ${shortcut}`, {
184+ key: event.key,
185+ code: event.code,
186+ modifiers,
187+ target: event.target?.constructor.name
188+ });
189+ }
190+
191+ public logMouseEvent(event: MouseEvent): void {
192+ const buttonNames: Record<number, string> = {
193+ 0: 'Left',
194+ 1: 'Middle',
195+ 2: 'Right',
196+ 3: 'Back',
197+ 4: 'Forward'
198+ };
199+
200+ this.addLog('info', 'frontend', 'mouse', `Mouse ${event.type}: ${buttonNames[event.button] || `Button${event.button}`}`, {
201+ button: event.button,
202+ clientX: event.clientX,
203+ clientY: event.clientY,
204+ target: event.target?.constructor.name
205+ });
206+ }
207+
208+ public logServiceCall(serviceName: string, methodName: string, params: any[], result?: any, error?: Error): void {
209+ if (error) {
210+ this.addLog('error', 'frontend', 'service', `${serviceName}.${methodName}() failed`, {
211+ params,
212+ error: error.message
213+ });
214+ } else {
215+ this.addLog('info', 'frontend', 'service', `${serviceName}.${methodName}() called`, {
216+ params,
217+ result
218+ });
219+ }
220+ }
221+
222+ public logBackendMessage(message: string, data?: any): void {
223+ this.addLog('info', 'backend', 'service', message, data);
224+ }
225+
226+ public show(): void {
227+ if (this.debugElement) {
228+ this.debugElement.style.display = 'block';
229+ this.isVisible = true;
230+ this.addLog('info', 'frontend', 'debug', 'Debug console opened');
231+ }
232+ }
233+
234+ public hide(): void {
235+ if (this.debugElement) {
236+ this.debugElement.style.display = 'none';
237+ this.isVisible = false;
238+ }
239+ }
240+
241+ public toggle(): void {
242+ if (this.isVisible) {
243+ this.hide();
244+ } else {
245+ this.show();
246+ }
247+ }
248+
249+ public clearLogs(): void {
250+ this.logs = [];
251+ this.updateDisplay();
252+ this.addLog('info', 'frontend', 'debug', 'Logs cleared');
253+ }
254+
255+ public isDebugVisible(): boolean {
256+ return this.isVisible;
257+ }
258+}
A gui/src/services/notifications.ts +91 -0
@@ -0,0 +1,91 @@
1+import type { NotificationOptions } from '../types/app.js';
2+
3+export class NotificationService {
4+ private zoomNotification: HTMLElement | null = null;
5+ private generalNotification: HTMLElement | null = null;
6+
7+ showZoom(message: string, duration = 2000): void {
8+ // Create or update zoom notification
9+ if (!this.zoomNotification) {
10+ this.zoomNotification = this.createNotificationElement('zoom-notification', {
11+ top: '20px',
12+ right: '20px',
13+ background: 'rgba(0, 0, 0, 0.8)',
14+ color: 'white'
15+ });
16+ }
17+
18+ this.updateNotification(this.zoomNotification, message, duration);
19+ }
20+
21+ show(message: string, type: NotificationOptions['type'] = 'info', duration = 3000): void {
22+ // Create or update general notification
23+ if (!this.generalNotification) {
24+ this.generalNotification = this.createNotificationElement('general-notification', {
25+ top: '60px',
26+ right: '20px'
27+ });
28+ }
29+
30+ // Set colors based on type
31+ const colors = this.getNotificationColors(type);
32+ this.generalNotification.style.background = colors.background;
33+ this.generalNotification.style.color = colors.color;
34+
35+ this.updateNotification(this.generalNotification, message, duration);
36+ }
37+
38+ private createNotificationElement(id: string, baseStyles: Record<string, string>): HTMLElement {
39+ const notification = document.createElement('div');
40+ notification.id = id;
41+
42+ const styles = {
43+ position: 'fixed',
44+ padding: '10px 15px',
45+ borderRadius: '5px',
46+ fontSize: '14px',
47+ zIndex: '10000',
48+ transition: 'opacity 0.3s',
49+ opacity: '0',
50+ ...baseStyles
51+ };
52+
53+ notification.style.cssText = Object.entries(styles)
54+ .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
55+ .join('; ');
56+
57+ document.body.appendChild(notification);
58+ return notification;
59+ }
60+
61+ private updateNotification(element: HTMLElement, message: string, duration: number): void {
62+ element.textContent = message;
63+ element.style.opacity = '1';
64+
65+ // Clear any existing timeout
66+ const existingTimeout = (element as any).timeout;
67+ if (existingTimeout) {
68+ clearTimeout(existingTimeout);
69+ }
70+
71+ // Hide after duration
72+ (element as any).timeout = setTimeout(() => {
73+ element.style.opacity = '0';
74+ }, duration);
75+ }
76+
77+ private getNotificationColors(type: NotificationOptions['type']): { background: string; color: string } {
78+ switch (type) {
79+ case 'error':
80+ return { background: 'rgba(220, 53, 69, 0.9)', color: 'white' };
81+ case 'success':
82+ return { background: 'rgba(25, 135, 84, 0.9)', color: 'white' };
83+ default:
84+ return { background: 'rgba(13, 110, 253, 0.9)', color: 'white' };
85+ }
86+ }
87+
88+ private camelToKebab(str: string): string {
89+ return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
90+ }
91+}⏎̸
A gui/src/styles/tailwind.css +252 -0
@@ -0,0 +1,252 @@
1+@import "tailwindcss";
2+
3+/* Define custom theme colors for v4 */
4+@theme {
5+ /* Light theme colors (default) */
6+ --color-browser-bg: rgb(255 255 255); /* white */
7+ --color-browser-surface: rgb(249 250 251); /* gray-50 */
8+ --color-browser-text: rgb(17 24 39); /* gray-900 */
9+ --color-browser-muted: rgb(107 114 128); /* gray-500 */
10+ --color-browser-accent: rgb(59 130 246); /* blue-500 */
11+ --color-browser-border: rgb(229 231 235); /* gray-200 */
12+}
13+
14+/* Dynamic theme switching via CSS variables */
15+:root {
16+ --dynamic-bg: var(--color-browser-bg);
17+ --dynamic-surface: var(--color-browser-surface);
18+ --dynamic-text: var(--color-browser-text);
19+ --dynamic-muted: var(--color-browser-muted);
20+ --dynamic-accent: var(--color-browser-accent);
21+ --dynamic-border: var(--color-browser-border);
22+}
23+
24+/* Dark theme colors (applied via .dark class from GTK) */
25+.dark {
26+ --dynamic-bg: rgb(17 24 39); /* gray-900 */
27+ --dynamic-surface: rgb(31 41 55); /* gray-800 */
28+ --dynamic-text: rgb(243 244 246); /* gray-100 */
29+ --dynamic-muted: rgb(156 163 175); /* gray-400 */
30+ --dynamic-accent: rgb(96 165 250); /* blue-400 */
31+ --dynamic-border: rgb(55 65 81); /* gray-700 */
32+}
33+
34+/* Base styles for browser UI components */
35+.browser-ui {
36+ background-color: var(--dynamic-bg);
37+ color: var(--dynamic-text);
38+ border-color: var(--dynamic-border);
39+ transition: colors 250ms;
40+}
41+
42+/* Toast notification styles */
43+.toast-base {
44+ border-radius: 0.5rem;
45+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
46+ padding: 0.75rem 1rem;
47+ max-width: 24rem;
48+ pointer-events: auto;
49+}
50+
51+.toast-success {
52+ background-color: rgb(240 253 244);
53+ color: rgb(22 163 74);
54+ border: 1px solid rgb(187 247 208);
55+}
56+
57+.dark .toast-success {
58+ background-color: rgb(22 163 74 / 0.2);
59+ color: rgb(187 247 208);
60+ border-color: rgb(22 163 74);
61+}
62+
63+.toast-error {
64+ background-color: rgb(254 242 242);
65+ color: rgb(220 38 38);
66+ border: 1px solid rgb(254 202 202);
67+}
68+
69+.dark .toast-error {
70+ background-color: rgb(220 38 38 / 0.2);
71+ color: rgb(254 202 202);
72+ border-color: rgb(220 38 38);
73+}
74+
75+.toast-info {
76+ background-color: rgb(239 246 255);
77+ color: rgb(59 130 246);
78+ border: 1px solid rgb(191 219 254);
79+}
80+
81+.dark .toast-info {
82+ background-color: rgb(59 130 246 / 0.2);
83+ color: rgb(191 219 254);
84+ border-color: rgb(59 130 246);
85+}
86+
87+/* Omnibox/Find bar styles */
88+.omnibox-base {
89+ background-color: var(--dynamic-bg);
90+ color: var(--dynamic-text);
91+ border: 2px solid var(--dynamic-border);
92+ border-radius: 0.375rem;
93+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
94+ transition: all 150ms;
95+}
96+
97+.omnibox-base:focus-within {
98+ border-color: var(--dynamic-accent);
99+}
100+
101+.omnibox-input {
102+ width: 100%;
103+ padding: 0.5rem 0.75rem;
104+ background-color: transparent;
105+ color: var(--dynamic-text);
106+ outline: none;
107+ font-size: 0.875rem;
108+}
109+
110+.omnibox-input::placeholder {
111+ color: var(--dynamic-muted);
112+}
113+
114+.omnibox-results {
115+ background-color: var(--dynamic-bg);
116+ border: 1px solid var(--dynamic-border);
117+ border-radius: 0.375rem;
118+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
119+ max-height: 24rem;
120+ overflow-y: auto;
121+}
122+
123+.omnibox-item {
124+ padding: 0.75rem 1rem;
125+ cursor: pointer;
126+ transition: colors 150ms;
127+ border-bottom: 1px solid var(--dynamic-border);
128+}
129+
130+.omnibox-item:hover,
131+.omnibox-item:focus {
132+ background-color: var(--dynamic-surface);
133+ outline: none;
134+}
135+
136+.omnibox-item.selected {
137+ background-color: color-mix(in srgb, var(--dynamic-accent) 10%, transparent);
138+ color: var(--dynamic-accent);
139+}
140+
141+/* Find bar specific styles */
142+.findbar {
143+ display: flex;
144+ align-items: center;
145+ gap: 0.5rem;
146+ padding: 0.5rem 0.75rem;
147+}
148+
149+.findbar-input {
150+ flex: 1;
151+ background-color: transparent;
152+ color: var(--dynamic-text);
153+ outline: none;
154+ font-size: 0.875rem;
155+}
156+
157+.findbar-input::placeholder {
158+ color: var(--dynamic-muted);
159+}
160+
161+.findbar-button {
162+ padding: 0.25rem 0.5rem;
163+ color: var(--dynamic-muted);
164+ transition: colors 150ms;
165+ font-size: 0.875rem;
166+}
167+
168+.findbar-button:hover {
169+ color: var(--dynamic-text);
170+}
171+
172+.findbar-count {
173+ font-size: 0.75rem;
174+ color: var(--dynamic-muted);
175+ font-variant-numeric: tabular-nums;
176+}
177+
178+/* Animation utilities */
179+.slide-in-top {
180+ animation: slide-in-top 0.25s ease-out;
181+}
182+
183+.slide-out-top {
184+ animation: slide-out-top 0.25s ease-in;
185+}
186+
187+.fade-in {
188+ animation: fade-in 0.15s ease-out;
189+}
190+
191+.fade-out {
192+ animation: fade-out 0.15s ease-in;
193+}
194+
195+@keyframes slide-in-top {
196+ from {
197+ transform: translateY(-100%);
198+ opacity: 0;
199+ }
200+ to {
201+ transform: translateY(0);
202+ opacity: 1;
203+ }
204+}
205+
206+@keyframes slide-out-top {
207+ from {
208+ transform: translateY(0);
209+ opacity: 1;
210+ }
211+ to {
212+ transform: translateY(-100%);
213+ opacity: 0;
214+ }
215+}
216+
217+@keyframes fade-in {
218+ from { opacity: 0; }
219+ to { opacity: 1; }
220+}
221+
222+@keyframes fade-out {
223+ from { opacity: 1; }
224+ to { opacity: 0; }
225+}
226+
227+/* Find highlighting styles for in-page search */
228+.find-highlight {
229+ background-color: rgb(255 235 59); /* yellow-400 */
230+ color: rgb(0 0 0); /* black */
231+ padding: 0 0.125rem; /* px-0.5 */
232+ border-radius: 0.125rem; /* rounded-sm */
233+ box-shadow: 0 0 0 1px rgb(200 185 0) inset;
234+}
235+
236+.find-active {
237+ background-color: rgb(255 152 0) !important; /* orange-500 */
238+ color: rgb(0 0 0); /* black */
239+ box-shadow: 0 0 0 1px rgb(179 107 0) inset !important;
240+}
241+
242+/* Utility for isolating component styles */
243+.browser-component-root {
244+ /* Reset any inherited styles from the website */
245+ all: initial;
246+ /* Apply our design system */
247+ font-family: Fira Sans, system-ui, -apple-system, sans-serif;
248+ box-sizing: border-box;
249+ * {
250+ box-sizing: border-box;
251+ }
252+}⏎̸
A gui/src/types/app.ts +26 -0
@@ -0,0 +1,26 @@
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';⏎̸
A gui/src/types/generated.ts +80 -0
@@ -0,0 +1,80 @@
1+// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
2+// Auto-generated TypeScript types from Go structs
3+// Generated on: 2025-09-09T09:57:41.059Z
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 History {
9+ id: number;
10+ url: string;
11+ title: string | null;
12+ visit_count: number | null;
13+ last_visited: string | null;
14+ created_at: string | null;
15+}
16+
17+export interface Shortcut {
18+ id: number;
19+ shortcut: string;
20+ url_template: string;
21+ description: string | null;
22+ created_at: string | null;
23+}
24+
25+export interface ZoomSetting {
26+ id: number;
27+ url: string;
28+ zoom_level: number;
29+ last_updated: string | null;
30+}
31+
32+export interface ParseResult {
33+ type: InputType;
34+ url: string;
35+ query: string;
36+ confidence: number;
37+ fuzzy_matches?: FuzzyMatch[];
38+ shortcut?: DetectedShortcut;
39+ processing_time: number;
40+}
41+
42+export interface DetectedShortcut {
43+ key: string;
44+ query: string;
45+ url: string;
46+ description: string;
47+}
48+
49+export interface FuzzyMatch {
50+ history_entry?: History;
51+ score: number;
52+ url_score: number;
53+ title_score: number;
54+ recency_score: number;
55+ visit_score: number;
56+ matched_field: string;
57+}
58+
59+export interface FuzzyConfig {
60+ min_similarity_threshold: number;
61+ max_results: number;
62+ url_weight: number;
63+ title_weight: number;
64+ recency_weight: number;
65+ visit_weight: number;
66+ recency_decay_days: number;
67+}
68+
69+// Manual types and enums
70+export enum InputType {
71+ InputTypeDirectURL = 0,
72+ InputTypeSearchShortcut = 1,
73+ InputTypeHistorySearch = 2,
74+ InputTypeFallbackSearch = 3
75+}
76+
77+// Type aliases for frontend usage
78+export type HistoryEntry = History;
79+export type SearchShortcut = Shortcut;
80+export type ZoomEntry = ZoomSetting;
A gui/src/utils/dom.ts +196 -0
@@ -0,0 +1,196 @@
1+type HistoryEntry = { id: number; url: string; title: string };
2+type SearchShortcut = { description: string; url: string };
3+
4+export class DOMRenderer {
5+ private historyListElement: HTMLElement | null = null;
6+ private shortcutsElement: HTMLElement | null = null;
7+
8+ constructor() {
9+ this.historyListElement = document.getElementById('historyList');
10+ this.shortcutsElement = document.getElementById('shortcuts');
11+ }
12+
13+ displayHistory(historyEntries: HistoryEntry[]): void {
14+ if (!this.historyListElement) return;
15+
16+ if (!historyEntries || historyEntries.length === 0) {
17+ this.historyListElement.innerHTML = `
18+ <div class="empty-state">
19+ <h3>No history yet</h3>
20+ <p>Use CLI commands to browse websites</p>
21+ </div>
22+ `;
23+ return;
24+ }
25+
26+ this.historyListElement.innerHTML = '';
27+ historyEntries.forEach(item => {
28+ const historyItem = this.createHistoryItem(item);
29+ this.historyListElement!.appendChild(historyItem);
30+ });
31+ }
32+
33+ displayShortcuts(shortcutsData: Record<string, SearchShortcut>): void {
34+ if (!this.shortcutsElement) return;
35+
36+ if (!shortcutsData || Object.keys(shortcutsData).length === 0) {
37+ this.shortcutsElement.innerHTML = `
38+ <div class="empty-state">
39+ <h3>No shortcuts configured</h3>
40+ <p>Add shortcuts to your config file</p>
41+ </div>
42+ `;
43+ return;
44+ }
45+
46+ this.shortcutsElement.innerHTML = '';
47+ Object.entries(shortcutsData).forEach(([key, shortcut]) => {
48+ const shortcutEl = this.createShortcutElement(key, shortcut);
49+ this.shortcutsElement!.appendChild(shortcutEl);
50+ });
51+ }
52+
53+ showLoading(container: 'history' | 'shortcuts'): void {
54+ const element = container === 'history' ? this.historyListElement : this.shortcutsElement;
55+ if (element) {
56+ element.innerHTML = '<div class="loading">Loading...</div>';
57+ }
58+ }
59+
60+ showError(container: 'history' | 'shortcuts', message: string): void {
61+ const element = container === 'history' ? this.historyListElement : this.shortcutsElement;
62+ if (element) {
63+ element.innerHTML = `
64+ <div class="empty-state">
65+ <h3>Error</h3>
66+ <p>${message}</p>
67+ </div>
68+ `;
69+ }
70+ }
71+
72+ private createHistoryItem(item: HistoryEntry): HTMLElement {
73+ const historyItem = document.createElement('div');
74+ historyItem.className = 'history-item';
75+ const parsed = this.safeParseURL(item.url);
76+ const title = item.title && item.title.trim() !== '' ? item.title : (parsed.host || 'Untitled');
77+
78+ const line = document.createElement('div');
79+ line.className = 'history-line';
80+
81+ // Favicon chip (wrapper ensures perfect circle sizing)
82+ const iconWrap = document.createElement('div');
83+ iconWrap.className = 'history-favicon-chip';
84+ const icon = document.createElement('img');
85+ icon.className = 'history-favicon-img';
86+ icon.width = 16; icon.height = 16;
87+ icon.loading = 'lazy';
88+ icon.referrerPolicy = 'no-referrer';
89+ icon.src = this.buildFaviconURL(item.url);
90+ icon.addEventListener('error', () => { iconWrap.style.display = 'none'; });
91+ iconWrap.appendChild(icon);
92+
93+ const titleEl = document.createElement('span');
94+ titleEl.className = 'history-title';
95+ titleEl.textContent = title;
96+
97+ const sep1 = document.createElement('span');
98+ sep1.className = 'history-sep';
99+ sep1.textContent = ' - ';
100+
101+ const domainEl = document.createElement('span');
102+ domainEl.className = 'history-domain';
103+ domainEl.textContent = parsed.host || '';
104+
105+ const sep2 = document.createElement('span');
106+ sep2.className = 'history-sep';
107+ sep2.textContent = ' - ';
108+
109+ const urlEl = document.createElement('span');
110+ urlEl.className = 'history-url';
111+ urlEl.textContent = item.url;
112+
113+ line.appendChild(iconWrap);
114+ line.appendChild(titleEl);
115+ line.appendChild(sep1);
116+ line.appendChild(domainEl);
117+ line.appendChild(sep2);
118+ line.appendChild(urlEl);
119+
120+ historyItem.appendChild(line);
121+
122+ historyItem.addEventListener('click', () => {
123+ this.navigateToUrl(item.url);
124+ });
125+
126+ return historyItem;
127+ }
128+
129+ private createShortcutElement(key: string, shortcut: SearchShortcut): HTMLElement {
130+ const shortcutEl = document.createElement('div');
131+ shortcutEl.className = 'shortcut';
132+ shortcutEl.innerHTML = `
133+ <div class="shortcut-key">${this.escapeHtml(key)}:</div>
134+ <div class="shortcut-desc">${this.escapeHtml(shortcut.description)}</div>
135+ `;
136+
137+ shortcutEl.addEventListener('click', () => {
138+ // Navigate to the base URL of the shortcut (remove %s template parameter)
139+ const baseUrl = this.extractBaseUrl(shortcut.url);
140+ this.navigateToUrl(baseUrl);
141+ });
142+
143+ return shortcutEl;
144+ }
145+
146+ private async navigateToUrl(url: string): Promise<void> {
147+ console.log('Navigating to:', url);
148+ window.location.href = url;
149+ }
150+
151+ private extractBaseUrl(templateUrl: string): string {
152+ try {
153+ // Support both %s and {query} placeholders
154+ const candidate = templateUrl.replace(/%s|\{query\}/g, "");
155+ const url = new URL(candidate);
156+ // Remove empty query params left after placeholder removal
157+ const params = new URLSearchParams(url.search);
158+ for (const key of Array.from(params.keys())) {
159+ const v = params.get(key);
160+ if (v === null || v === "") {
161+ params.delete(key);
162+ }
163+ }
164+ url.search = params.toString();
165+ return url.toString();
166+ } catch (error) {
167+ console.error('Failed to parse shortcut URL:', templateUrl, error);
168+ throw error;
169+ }
170+ }
171+
172+ private escapeHtml(text: string): string {
173+ const div = document.createElement('div');
174+ div.textContent = text;
175+ return div.innerHTML;
176+ }
177+
178+ private safeParseURL(raw: string): { host: string } {
179+ try {
180+ const u = new URL(raw);
181+ return { host: u.host };
182+ } catch {
183+ return { host: '' };
184+ }
185+ }
186+
187+ private buildFaviconURL(raw: string): string {
188+ try {
189+ const u = new URL(raw);
190+ const scheme = u.protocol && u.protocol !== ':' ? u.protocol : 'https:';
191+ return `${scheme}//${u.host}/favicon.ico`;
192+ } catch {
193+ return '';
194+ }
195+ }
196+}
A gui/src/utils/html-builder.ts +251 -0
@@ -0,0 +1,251 @@
1+export class HTMLBuilder {
2+ static createBaseHTML(): string {
3+ const faviconData = this.getFaviconDataURL();
4+ return `
5+<!DOCTYPE html>
6+<html lang="en">
7+<head>
8+ <meta charset="UTF-8">
9+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10+ <title>Dumber Browser</title>
11+ <link rel="icon" type="image/svg+xml" href="${faviconData}">
12+ <style>
13+ ${this.getStyles()}
14+ </style>
15+</head>
16+<body>
17+ <div class="container">
18+ <div class="history-section">
19+ <h2 class="section-title">Recent History</h2>
20+ <div id="historyList" class="history-list">
21+ <div class="loading">Loading history...</div>
22+ </div>
23+ </div>
24+
25+ <div class="shortcuts-section">
26+ <h2 class="section-title">Search Shortcuts</h2>
27+ <div id="shortcuts" class="shortcuts-grid">
28+ <div class="loading">Loading shortcuts...</div>
29+ </div>
30+ </div>
31+ </div>
32+ <script type="module" src="./main.js"></script>
33+</body>
34+</html>`;
35+ }
36+
37+ private static getFaviconDataURL(): string {
38+ const svg = HTMLBuilder.getFaviconSVG();
39+ // Minimal encoding for safe inline usage
40+ const encoded = encodeURIComponent(svg)
41+ .replace(/'/g, '%27')
42+ .replace(/\(/g, '%28')
43+ .replace(/\)/g, '%29');
44+ return `data:image/svg+xml;utf8,${encoded}`;
45+ }
46+
47+ static getFaviconSVG(): string {
48+ // Stylized D with speed bolt on a subtle dark rounded card
49+ return `<?xml version="1.0" encoding="UTF-8"?>
50+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
51+ <defs>
52+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
53+ <stop offset="0%" stop-color="#121212"/>
54+ <stop offset="100%" stop-color="#1e1e1e"/>
55+ </linearGradient>
56+ <filter id="inset" x="-20%" y="-20%" width="140%" height="140%">
57+ <feOffset dx="0" dy="1"/>
58+ <feGaussianBlur stdDeviation="0.6" result="blur"/>
59+ <feComposite in2="SourceAlpha" operator="out"/>
60+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
61+ <feBlend in="SourceGraphic" mode="normal"/>
62+ </filter>
63+ </defs>
64+ <rect x="4" y="4" width="56" height="56" rx="14" ry="14" fill="url(#bg)" filter="url(#inset)"/>
65+ <rect x="4.5" y="4.5" width="55" height="55" rx="13.5" ry="13.5" fill="none" stroke="#252a2f" stroke-width="1"/>
66+ <!-- D glyph (slightly larger and wider) -->
67+ <path d="M20 16 L20 48 M20 16 H34 q12 0 12 16 t-12 16 H20" fill="none" stroke="#e9eef3" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" transform="translate(33,32) scale(1.12,1.10) translate(-33,-32)"/>
68+ <!-- Speed bolt accent (significantly larger, centered) -->
69+ <path d="M36 22 L32 34 h6 l-8 16 4-12 h-6 z" fill="#ff9800" opacity="0.95" transform="translate(33,32) scale(1.8) translate(-33,-32) translate(0,-3)"/>
70+</svg>`;
71+ }
72+
73+ private static getStyles(): string {
74+ return `
75+ * {
76+ margin: 0;
77+ padding: 0;
78+ box-sizing: border-box;
79+ }
80+
81+ body {
82+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
83+ background: #1a1a1a;
84+ color: #ffffff;
85+ height: 100vh;
86+ display: flex;
87+ flex-direction: column;
88+ overflow-x: hidden; /* prevent horizontal scroll from long content */
89+ }
90+
91+ .container {
92+ flex: 1;
93+ display: flex;
94+ max-width: 1200px;
95+ margin: 0 auto;
96+ width: 100%;
97+ padding: 2rem;
98+ gap: 2rem;
99+ overflow-x: hidden;
100+ }
101+
102+ .history-section {
103+ flex: 1;
104+ min-height: 0;
105+ min-width: 0; /* allow flex child to shrink */
106+ }
107+
108+ .shortcuts-section {
109+ flex: 1;
110+ min-height: 0;
111+ min-width: 0; /* allow flex child to shrink */
112+ }
113+
114+ .section-title {
115+ font-size: 1.5rem;
116+ margin-bottom: 1rem;
117+ color: #ffffff;
118+ border-bottom: 2px solid #404040;
119+ padding-bottom: 0.5rem;
120+ }
121+
122+ .history-list {
123+ overflow-y: auto;
124+ overflow-x: hidden;
125+ max-height: calc(100vh - 8rem);
126+ max-width: 100%;
127+ }
128+
129+ .history-item {
130+ padding: 0.75rem;
131+ margin-bottom: 0.5rem;
132+ background: #2d2d2d;
133+ border-radius: 6px;
134+ cursor: pointer;
135+ transition: background 0.2s;
136+ border-left: 3px solid #404040;
137+ overflow: hidden;
138+ max-width: 100%;
139+ }
140+
141+ .history-item:hover {
142+ background: #3d3d3d;
143+ border-left-color: #0066cc;
144+ }
145+
146+ .history-line {
147+ display: flex;
148+ gap: 0.5rem;
149+ white-space: nowrap;
150+ align-items: center;
151+ overflow: hidden;
152+ width: 100%;
153+ min-width: 0; /* allow flex children to shrink */
154+ }
155+ .history-favicon-chip {
156+ flex: 0 0 20px;
157+ width: 20px; height: 20px;
158+ border-radius: 50%;
159+ background: #ccc;
160+ border: 1px solid rgba(0,0,0,.12);
161+ box-shadow: 0 1px 2px rgba(0,0,0,.12);
162+ display: flex;
163+ align-items: center;
164+ justify-content: center;
165+ }
166+ .history-favicon-img {
167+ width: 16px; height: 16px;
168+ filter: brightness(1.06) contrast(1.03);
169+ image-rendering: -webkit-optimize-contrast;
170+ }
171+
172+ .history-title { font-size: 0.95rem; color: #e6e6e6; flex: 0 0 auto; }
173+ .history-domain { font-size: 0.9rem; color: #a5a5a5; flex: 0 0 auto; }
174+ .history-sep { color: #666; flex: 0 0 auto; }
175+ .history-url {
176+ font-size: 0.9rem;
177+ color: #7a7a7a; /* darker than domain */
178+ flex: 1 1 auto;
179+ min-width: 0; /* critical for flex truncation */
180+ overflow: hidden;
181+ text-overflow: ellipsis; /* fallback */
182+ -webkit-mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%);
183+ mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%);
184+ }
185+
186+ .shortcuts-grid {
187+ display: grid;
188+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
189+ gap: 1rem;
190+ max-height: calc(100vh - 8rem);
191+ overflow-y: auto;
192+ }
193+
194+ .shortcut {
195+ padding: 1rem;
196+ background: #2d2d2d;
197+ border: 2px solid #404040;
198+ border-radius: 8px;
199+ cursor: pointer;
200+ transition: all 0.2s;
201+ text-align: center;
202+ }
203+
204+ .shortcut:hover {
205+ background: #3d3d3d;
206+ border-color: #0066cc;
207+ transform: translateY(-2px);
208+ }
209+
210+ .shortcut-key {
211+ font-weight: bold;
212+ color: #0066cc;
213+ margin-bottom: 0.5rem;
214+ font-size: 1.1rem;
215+ }
216+
217+ .shortcut-desc {
218+ color: #888;
219+ font-size: 0.9rem;
220+ }
221+
222+ .loading {
223+ padding: 2rem;
224+ text-align: center;
225+ color: #888;
226+ }
227+
228+ .empty-state {
229+ padding: 3rem 2rem;
230+ text-align: center;
231+ color: #666;
232+ }
233+
234+ .empty-state h3 {
235+ margin-bottom: 1rem;
236+ color: #888;
237+ }
238+
239+ @media (max-width: 768px) {
240+ .container {
241+ flex-direction: column;
242+ padding: 1rem;
243+ }
244+
245+ .shortcuts-grid {
246+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
247+ }
248+ }
249+ `;
250+ }
251+}
A gui/svelte.config.js +10 -0
@@ -0,0 +1,10 @@
1+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
2+
3+export default {
4+ // Enable rune mode for Svelte 5
5+ compilerOptions: {
6+ runes: true,
7+ },
8+ // Use Vite's preprocessor for TypeScript and PostCSS
9+ preprocess: vitePreprocess(),
10+};⏎̸
A gui/tailwind.config.js +44 -0
@@ -0,0 +1,44 @@
1+/** @type {import('tailwindcss').Config} */
2+export default {
3+ content: [
4+ './src/**/*.{html,js,ts,svelte}',
5+ ],
6+ theme: {
7+ extend: {
8+ // Custom design system colors
9+ colors: {
10+ // Browser-specific colors that work well in both themes
11+ browser: {
12+ bg: 'rgb(var(--browser-bg) / <alpha-value>)',
13+ surface: 'rgb(var(--browser-surface) / <alpha-value>)',
14+ text: 'rgb(var(--browser-text) / <alpha-value>)',
15+ muted: 'rgb(var(--browser-muted) / <alpha-value>)',
16+ accent: 'rgb(var(--browser-accent) / <alpha-value>)',
17+ border: 'rgb(var(--browser-border) / <alpha-value>)',
18+ }
19+ },
20+ // Animation speeds optimized for browser UI
21+ transitionDuration: {
22+ '150': '150ms', // Fast interactions
23+ '250': '250ms', // UI feedback
24+ '400': '400ms', // Smooth transitions
25+ },
26+ // Typography scale for browser UI
27+ fontSize: {
28+ 'xs': ['0.75rem', '1rem'],
29+ 'sm': ['0.875rem', '1.25rem'],
30+ 'base': ['1rem', '1.5rem'],
31+ 'lg': ['1.125rem', '1.75rem'],
32+ },
33+ // Spacing for compact UI elements
34+ spacing: {
35+ '18': '4.5rem',
36+ '22': '5.5rem',
37+ }
38+ },
39+ },
40+ plugins: [],
41+ // Manual dark mode control (not system preference)
42+ // This allows GTK to control theme via CSS class
43+ darkMode: 'class',
44+};⏎̸
A gui/tsconfig.json +33 -0
@@ -0,0 +1,33 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7+ "allowImportingTsExtensions": true,
8+ "moduleDetection": "force",
9+ "noEmit": true,
10+ "strict": true,
11+ "noUnusedLocals": true,
12+ "noUnusedParameters": true,
13+ "noFallthroughCasesInSwitch": true,
14+ "noUncheckedIndexedAccess": true,
15+ "skipLibCheck": true,
16+ "esModuleInterop": true,
17+ "allowSyntheticDefaultImports": true,
18+ "forceConsistentCasingInFileNames": true,
19+ "baseUrl": ".",
20+ "paths": {
21+ "$lib/*": ["src/lib/*"],
22+ "$components/*": ["src/components/*"]
23+ }
24+ },
25+ "include": [
26+ "src/**/*",
27+ "vite.config.ts"
28+ ],
29+ "exclude": [
30+ "node_modules",
31+ "dist"
32+ ]
33+}⏎̸
A gui/vite-plugin-pages.ts +98 -0
@@ -0,0 +1,98 @@
1+import type { Plugin } from 'vite';
2+import { writeFileSync, readFileSync } from 'fs';
3+import { resolve } from 'path';
4+
5+interface PageConfig {
6+ /** Page name (used for filename and script name) */
7+ name: string;
8+ /** Page title */
9+ title: string;
10+ /** Script filename (e.g., 'homepage.min.js') */
11+ script: string;
12+ /** Optional CSS filename */
13+ css?: string;
14+ /** Target filename (defaults to name + '.html') */
15+ filename?: string;
16+}
17+
18+// Load the favicon SVG from assets
19+function getFaviconSVG(): string {
20+ const logoPath = resolve(__dirname, '..', 'assets', 'logo.svg');
21+ return readFileSync(logoPath, 'utf-8');
22+}
23+
24+// Create favicon data URL for inline usage
25+function getFaviconDataURL(): string {
26+ const svg = getFaviconSVG();
27+ const encoded = encodeURIComponent(svg)
28+ .replace(/'/g, '%27')
29+ .replace(/\(/g, '%28')
30+ .replace(/\)/g, '%29');
31+ return `data:image/svg+xml;utf8,${encoded}`;
32+}
33+
34+// Generate HTML content for a page
35+function generatePageHTML(config: PageConfig): string {
36+ const cssLink = config.css ? `<link rel="stylesheet" href="./${config.css}">` : '';
37+
38+ return `<!DOCTYPE html>
39+<html lang="en">
40+<head>
41+ <meta charset="UTF-8">
42+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
43+ <title>${config.title}</title>
44+ <link rel="icon" type="image/svg+xml" href="${getFaviconDataURL()}">
45+ ${cssLink}
46+</head>
47+<body>
48+ <script src="./${config.script}"></script>
49+</body>
50+</html>`;
51+}
52+
53+/**
54+ * Vite plugin to generate dumb:// protocol pages
55+ * Supports multiple pages like homepage, config, about, etc.
56+ */
57+export function pageGenerator(pages: PageConfig[]): Plugin {
58+ return {
59+ name: 'dumb-page-generator',
60+ writeBundle() {
61+ const assetsDir = resolve(__dirname, '..', 'assets', 'gui');
62+
63+ try {
64+ // Generate HTML files for each page
65+ for (const page of pages) {
66+ const htmlContent = generatePageHTML(page);
67+ const filename = page.filename || `${page.name}.html`;
68+ writeFileSync(resolve(assetsDir, filename), htmlContent);
69+ console.log(`✓ Generated ${filename} for dumb://${page.name}`);
70+ }
71+
72+ // Generate favicon.svg for scheme handler lookups
73+ const faviconContent = getFaviconSVG();
74+ writeFileSync(resolve(assetsDir, 'favicon.svg'), faviconContent);
75+ console.log('✓ Generated favicon.svg');
76+
77+ } catch (error) {
78+ console.error('✗ Failed to generate page files:', error);
79+ throw error;
80+ }
81+ }
82+ };
83+}
84+
85+/**
86+ * Convenience function for homepage generation
87+ * @deprecated Use pageGenerator with homepage config instead
88+ */
89+export function homepageGenerator(): Plugin {
90+ return pageGenerator([
91+ {
92+ name: 'homepage',
93+ title: 'Dumber Browser',
94+ script: 'homepage.min.js',
95+ filename: 'index.html', // Special case: homepage uses index.html
96+ }
97+ ]);
98+}⏎̸
A gui/vite.config.gui.ts +35 -0
@@ -0,0 +1,35 @@
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+ ],
9+ build: {
10+ rollupOptions: {
11+ input: resolve(__dirname, 'src/injected/gui.ts'),
12+ output: {
13+ dir: '../assets/gui',
14+ format: 'iife',
15+ entryFileNames: 'gui.min.js',
16+ name: '__dumberGUI',
17+ inlineDynamicImports: true,
18+ assetFileNames: '[name].[ext]', // Don't hash asset names
19+ },
20+ },
21+ emptyOutDir: false,
22+ target: ['es2020', 'chrome91', 'firefox90'],
23+ minify: true,
24+ sourcemap: false,
25+ cssCodeSplit: false, // Keep CSS in single file
26+ assetsInlineLimit: 0, // Don't inline assets by default
27+ },
28+ resolve: {
29+ alias: {
30+ // Alias for easier imports
31+ '$lib': resolve(__dirname, 'src/lib'),
32+ '$components': resolve(__dirname, 'src/components'),
33+ },
34+ },
35+});⏎̸
A gui/vite.config.homepage.ts +51 -0
@@ -0,0 +1,51 @@
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+ pageGenerator([
10+ {
11+ name: 'homepage',
12+ title: 'Dumber Browser',
13+ script: 'homepage.min.js',
14+ css: 'homepage.css',
15+ filename: 'index.html', // Special case: homepage uses index.html
16+ }
17+ ]),
18+ ],
19+ build: {
20+ rollupOptions: {
21+ input: resolve(__dirname, 'src/pages/homepage.ts'),
22+ output: {
23+ dir: '../assets/gui',
24+ format: 'iife',
25+ entryFileNames: 'homepage.min.js',
26+ name: '__dumberHomepage',
27+ inlineDynamicImports: true,
28+ assetFileNames: (assetInfo) => {
29+ // Keep CSS as homepage.css to avoid conflicts with the main GUI
30+ if (assetInfo.name?.endsWith('.css')) {
31+ return 'homepage.[ext]';
32+ }
33+ return '[name].[ext]';
34+ },
35+ },
36+ },
37+ emptyOutDir: false, // Don't clear the directory (shared with GUI build)
38+ target: ['es2020', 'chrome91', 'firefox90'],
39+ minify: true,
40+ sourcemap: false,
41+ cssCodeSplit: false, // Keep CSS in single file
42+ assetsInlineLimit: 0, // Don't inline assets by default
43+ },
44+ resolve: {
45+ alias: {
46+ // Alias for easier imports
47+ '$lib': resolve(__dirname, 'src/lib'),
48+ '$components': resolve(__dirname, 'src/components'),
49+ },
50+ },
51+});⏎̸

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