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 | + | });⏎̸ | |