├── image.png ├── manifest.json ├── contentStyles.css ├── README.md ├── background.js └── contentScript.js /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dimillian/HyperGit-Chrome/main/image.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "HyperGit Quick Switcher", 4 | "description": "Cmd+K to search and open your GitHub repositories. Caches repos locally.", 5 | "version": "0.1.0", 6 | "action": { 7 | "default_title": "HyperGit" 8 | }, 9 | "background": { 10 | "service_worker": "background.js", 11 | "type": "module" 12 | }, 13 | "permissions": [ 14 | "storage", 15 | "activeTab", 16 | "scripting", 17 | "tabs" 18 | ], 19 | "host_permissions": [ 20 | "https://api.github.com/*", 21 | "https://github.com/*" 22 | ], 23 | "commands": { 24 | "toggle-palette": { 25 | "suggested_key": { 26 | "default": "Ctrl+K", 27 | "mac": "Command+K" 28 | }, 29 | "description": "Toggle HyperGit quick switcher" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /contentStyles.css: -------------------------------------------------------------------------------- 1 | .hg-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); backdrop-filter: saturate(1.2) blur(2px); z-index: 2147483646; display: none; } 2 | .hg-container, .hg-container * { box-sizing: border-box; } 3 | .hg-container { position: fixed; top: 6%; left: 50%; transform: translateX(-50%); width: min(800px, 92vw); max-height: 90vh; background: #111; color: #eee; border: 1px solid #333; border-radius: 12px; box-shadow: 0 18px 60px rgba(0,0,0,0.55); z-index: 2147483647; display: none; font-family: ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden; display: flex; flex-direction: column; } 4 | .hg-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; color: #aaa; border-bottom: 1px solid #222; flex: 0 0 auto; } 5 | .hg-header-title { font-weight: 600; } 6 | .hg-header-actions { display: flex; gap: 8px; } 7 | .hg-icon-btn { background: #1a1a1a; color: #ddd; border: 1px solid #2a2a2a; border-radius: 8px; padding: 6px 10px; cursor: pointer; } 8 | .hg-icon-btn:hover { background: #222; } 9 | .hg-input { width: calc(100% - 24px); margin: 12px; padding: 10px 12px; background: #0b0b0b; color: #eee; border: 1px solid #222; border-radius: 8px; outline: none; flex: 0 0 auto; } 10 | .hg-list { padding: 8px 6px 10px 6px; overflow: auto; flex: 1 1 auto; min-height: 0; } 11 | .hg-item { padding: 10px 10px; border-radius: 8px; cursor: pointer; } 12 | .hg-item:hover { background: #161616; } 13 | .hg-item.selected { background: #1d1d1d; } 14 | .hg-item-title { font-size: 14px; color: #eaeaea; } 15 | .hg-item-desc { font-size: 12px; color: #9aa0a6; margin-top: 4px; } 16 | .hg-empty { padding: 16px; text-align: center; color: #9aa0a6; } 17 | .hg-btn { margin-left: 8px; padding: 8px 12px; background: #2d6cdf; color: white; border: none; border-radius: 8px; cursor: pointer; } 18 | .hg-btn-secondary { background: #2a2a2a; color: #ddd; } 19 | .hg-token-panel { padding: 10px 10px 2px 10px; } 20 | .hg-token-row { display: flex; gap: 8px; align-items: center; } 21 | .hg-token-row .hg-input { flex: 1; } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HyperGit Quick Switcher 2 | 3 | ![HyperGit image](image.png) 4 | 5 | Cmd+K to search and open your GitHub repositories from any page. Caches repos locally for instant results. 6 | 7 | ## Features 8 | - Login via Personal Access Token (PAT) 9 | - Fetches and caches all your repositories (respects private if token has scope) 10 | - Cmd+K / Ctrl+K to toggle palette 11 | - Type to filter, arrow up/down to navigate, Enter to open 12 | - Remembers recent repositories and surfaces them first 13 | 14 | ## Why PAT and not OAuth? 15 | Chrome extensions cannot safely store a GitHub OAuth client secret, and GitHub OAuth flows require the secret for token exchange. To avoid needing a backend server, this extension uses a user-provided PAT stored locally via `chrome.storage.local`. 16 | 17 | You can switch to a backend + OAuth later by replacing the token handling in `background.js`. 18 | 19 | ## Create a GitHub PAT 20 | 1. Go to GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens 21 | 2. Create a token with at least: 22 | - For public repos only: no extra scopes required beyond default repo read 23 | - For private repos: grant repository access (read) to the desired resources 24 | 3. Copy the token (`ghp_...`). 25 | 26 | ## Install in Chrome 27 | 1. Open chrome://extensions 28 | 2. Enable "Developer mode" 29 | 3. Click "Load unpacked" and select this folder 30 | 31 | ## Usage 32 | - On any page, press Cmd+K (macOS) or Ctrl+K (Windows/Linux) to toggle the palette 33 | - If prompted, paste your GitHub PAT and click "Save Token" 34 | - The extension will fetch and cache your repos (may take a moment for large accounts) 35 | - Start typing to filter; use arrow keys to navigate; press Enter to open the selected repo 36 | 37 | ## Development Notes 38 | - Manifest V3 service worker: `background.js` 39 | - Content UI: `contentScript.js` + `contentStyles.css` 40 | - Storage keys and cache TTL are defined at the top of `background.js` 41 | - Recent repos are tracked by `full_name` and prioritized in results 42 | 43 | ## Permissions 44 | - `storage` for token and repo cache 45 | - `tabs`, `activeTab`, `scripting` to inject the palette and open tabs 46 | - Host permissions for `api.github.com` and `github.com` to fetch/open 47 | 48 | ## Switching to OAuth Later 49 | If you add a small backend, you can implement the GitHub Web Application Flow safely: 50 | - Use `chrome.identity.launchWebAuthFlow` for user login 51 | - Exchange the authorization code for a token on your backend (keeps client secret server-side) 52 | - Store/access the token via the extension as in the PAT flow 53 | 54 | ## Troubleshooting 55 | - If Cmd+K is taken by a site, change the shortcut in chrome://extensions → Keyboard shortcuts 56 | - Clear token: toggle the palette, then remove and re-add the extension or implement a small UI to call `clearToken` 57 | - Large orgs may need additional read scopes to list private repos 58 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | // Background Service Worker (MV3) 2 | 3 | const STORAGE_KEYS = { 4 | token: 'github_token', 5 | repos: 'github_repos', 6 | repoCacheAt: 'github_repos_cached_at', 7 | recents: 'recent_repo_full_names' 8 | }; 9 | 10 | const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours 11 | 12 | async function getFromStorage(keys) { 13 | return new Promise(resolve => chrome.storage.local.get(keys, resolve)); 14 | } 15 | 16 | async function setInStorage(obj) { 17 | return new Promise(resolve => chrome.storage.local.set(obj, resolve)); 18 | } 19 | 20 | async function removeFromStorage(keys) { 21 | return new Promise(resolve => chrome.storage.local.remove(keys, resolve)); 22 | } 23 | 24 | async function getToken() { 25 | const { [STORAGE_KEYS.token]: token } = await getFromStorage([STORAGE_KEYS.token]); 26 | return token || null; 27 | } 28 | 29 | async function setToken(token) { 30 | await setInStorage({ [STORAGE_KEYS.token]: token }); 31 | } 32 | 33 | async function clearToken() { 34 | await removeFromStorage([STORAGE_KEYS.token]); 35 | } 36 | 37 | async function fetchAllRepos(token) { 38 | const perPage = 100; 39 | let page = 1; 40 | let repos = []; 41 | while (true) { 42 | const url = `https://api.github.com/user/repos?per_page=${perPage}&page=${page}&sort=updated`; 43 | const res = await fetch(url, { 44 | headers: { 45 | Authorization: `Bearer ${token}`, 46 | Accept: 'application/vnd.github+json', 47 | 'X-GitHub-Api-Version': '2022-11-28' 48 | } 49 | }); 50 | if (res.status === 401) throw new Error('unauthorized'); 51 | if (!res.ok) throw new Error('fetch_failed'); 52 | const batch = await res.json(); 53 | repos = repos.concat(batch.map(r => ({ 54 | id: r.id, 55 | full_name: r.full_name, 56 | name: r.name, 57 | owner_login: r.owner?.login, 58 | private: r.private, 59 | stargazers_count: r.stargazers_count, 60 | updated_at: r.updated_at, 61 | html_url: r.html_url, 62 | description: r.description || '' 63 | }))); 64 | if (batch.length < perPage) break; 65 | page += 1; 66 | // Avoid hammering 67 | await new Promise(r => setTimeout(r, 200)); 68 | } 69 | return repos; 70 | } 71 | 72 | async function syncRepos(force = false) { 73 | const now = Date.now(); 74 | const { [STORAGE_KEYS.repoCacheAt]: cachedAt } = await getFromStorage([STORAGE_KEYS.repoCacheAt]); 75 | if (!force && cachedAt && now - cachedAt < CACHE_TTL_MS) { 76 | return; // fresh enough 77 | } 78 | const token = await getToken(); 79 | if (!token) return; 80 | const repos = await fetchAllRepos(token); 81 | await setInStorage({ 82 | [STORAGE_KEYS.repos]: repos, 83 | [STORAGE_KEYS.repoCacheAt]: now 84 | }); 85 | } 86 | 87 | async function getReposSortedWithRecents() { 88 | const { [STORAGE_KEYS.repos]: repos = [], [STORAGE_KEYS.recents]: recents = [] } = await getFromStorage([STORAGE_KEYS.repos, STORAGE_KEYS.recents]); 89 | if (!recents || recents.length === 0) return repos; 90 | const recentSet = new Set(recents); 91 | const repoMap = new Map(repos.map(r => [r.full_name, r])); 92 | const recentRepos = []; 93 | const otherRepos = []; 94 | // Build recentRepos in the order they appear in recents (most recent first) 95 | for (const fullName of recents) { 96 | const repo = repoMap.get(fullName); 97 | if (repo) recentRepos.push(repo); 98 | } 99 | // Add remaining repos that aren't in recents 100 | for (const r of repos) { 101 | if (!recentSet.has(r.full_name)) otherRepos.push(r); 102 | } 103 | return recentRepos.concat(otherRepos); 104 | } 105 | 106 | async function markRecent(fullName) { 107 | const { [STORAGE_KEYS.recents]: recents = [] } = await getFromStorage([STORAGE_KEYS.recents]); 108 | const next = [fullName].concat(recents.filter(r => r !== fullName)).slice(0, 20); 109 | await setInStorage({ [STORAGE_KEYS.recents]: next }); 110 | } 111 | 112 | async function openRepoInNewTab(url) { 113 | await chrome.tabs.create({ url }); 114 | } 115 | 116 | async function ensureContentInjectedAndToggle() { 117 | const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 118 | if (!tab || !tab.id) return; 119 | try { 120 | await chrome.scripting.insertCSS({ target: { tabId: tab.id }, files: ['contentStyles.css'] }); 121 | await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['contentScript.js'] }); 122 | } catch (e) { 123 | // ignore injection errors 124 | } 125 | await chrome.tabs.sendMessage(tab.id, { type: 'togglePalette' }); 126 | } 127 | 128 | chrome.runtime.onInstalled.addListener(() => { 129 | // initial warm cache (best-effort) 130 | syncRepos().catch(() => {}); 131 | }); 132 | 133 | chrome.commands.onCommand.addListener((command) => { 134 | if (command === 'toggle-palette') { 135 | ensureContentInjectedAndToggle(); 136 | } 137 | }); 138 | 139 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 140 | (async () => { 141 | if (message?.type === 'requestTokenStatus') { 142 | const token = await getToken(); 143 | sendResponse({ hasToken: !!token }); 144 | return; 145 | } 146 | if (message?.type === 'saveToken') { 147 | await setToken(message.token); 148 | await syncRepos(true); 149 | sendResponse({ ok: true }); 150 | return; 151 | } 152 | if (message?.type === 'clearToken') { 153 | await clearToken(); 154 | sendResponse({ ok: true }); 155 | return; 156 | } 157 | if (message?.type === 'getRepos') { 158 | await syncRepos(false); 159 | const repos = await getReposSortedWithRecents(); 160 | sendResponse({ repos }); 161 | return; 162 | } 163 | if (message?.type === 'refreshRepos') { 164 | await syncRepos(true); 165 | const repos = await getReposSortedWithRecents(); 166 | sendResponse({ repos }); 167 | return; 168 | } 169 | if (message?.type === 'openRepo') { 170 | const { url, full_name } = message; 171 | if (full_name) await markRecent(full_name); 172 | await openRepoInNewTab(url); 173 | sendResponse({ ok: true }); 174 | return; 175 | } 176 | })().catch(() => { 177 | try { sendResponse({ ok: false }); } catch (_) {} 178 | }); 179 | return true; // async 180 | }); 181 | -------------------------------------------------------------------------------- /contentScript.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | if (window.__hypergit_injected) return; 3 | window.__hypergit_injected = true; 4 | 5 | let paletteRoot = null; 6 | let shadow = null; 7 | let overlay = null; 8 | let container = null; 9 | let header = null; 10 | let headerActions = null; 11 | let tokenBtn = null; 12 | let refreshBtn = null; 13 | let input = null; 14 | let list = null; 15 | let visible = false; 16 | let repos = []; 17 | let filtered = []; 18 | let selectedIndex = 0; 19 | let hasToken = false; 20 | let showTokenPanel = false; 21 | 22 | function createUI() { 23 | paletteRoot = document.createElement('div'); 24 | paletteRoot.id = 'hypergit-root'; 25 | shadow = paletteRoot.attachShadow({ mode: 'closed' }); 26 | 27 | overlay = document.createElement('div'); 28 | overlay.className = 'hg-overlay'; 29 | overlay.addEventListener('click', (e) => { 30 | // Close only if clicking on the backdrop, not on the container 31 | if (e.target === overlay) hide(); 32 | }); 33 | 34 | container = document.createElement('div'); 35 | container.className = 'hg-container'; 36 | 37 | header = document.createElement('div'); 38 | header.className = 'hg-header'; 39 | const headerTitle = document.createElement('div'); 40 | headerTitle.textContent = 'HyperGit'; 41 | headerTitle.className = 'hg-header-title'; 42 | headerActions = document.createElement('div'); 43 | headerActions.className = 'hg-header-actions'; 44 | tokenBtn = document.createElement('button'); 45 | tokenBtn.className = 'hg-icon-btn'; 46 | tokenBtn.title = 'Token settings'; 47 | tokenBtn.textContent = 'Token'; 48 | tokenBtn.addEventListener('click', () => { 49 | showTokenPanel = !showTokenPanel; 50 | render(); 51 | }); 52 | refreshBtn = document.createElement('button'); 53 | refreshBtn.className = 'hg-icon-btn'; 54 | refreshBtn.title = 'Refresh repository list'; 55 | refreshBtn.textContent = '↻'; 56 | refreshBtn.addEventListener('click', async () => { 57 | refreshBtn.disabled = true; 58 | refreshBtn.textContent = '...'; 59 | try { 60 | await refreshData(true); 61 | selectedIndex = 0; 62 | render(); 63 | } catch (error) { 64 | console.error('Failed to refresh repos:', error); 65 | } finally { 66 | refreshBtn.disabled = false; 67 | refreshBtn.textContent = '↻'; 68 | } 69 | }); 70 | headerActions.appendChild(tokenBtn); 71 | headerActions.appendChild(refreshBtn); 72 | header.appendChild(headerTitle); 73 | header.appendChild(headerActions); 74 | 75 | input = document.createElement('input'); 76 | input.type = 'text'; 77 | input.placeholder = 'Search repositories...'; 78 | input.className = 'hg-input'; 79 | 80 | list = document.createElement('div'); 81 | list.className = 'hg-list'; 82 | 83 | container.appendChild(header); 84 | container.appendChild(input); 85 | container.appendChild(list); 86 | shadow.appendChild(overlay); 87 | shadow.appendChild(container); 88 | document.documentElement.appendChild(paletteRoot); 89 | 90 | input.addEventListener('input', onInput); 91 | // Listen on both window (capture) and input to reliably intercept keys 92 | window.addEventListener('keydown', onKeyDown, true); 93 | input.addEventListener('keydown', onKeyDown, true); 94 | 95 | injectBaseStyles(); 96 | } 97 | 98 | function render() { 99 | list.innerHTML = ''; 100 | if (refreshBtn) { 101 | refreshBtn.style.display = hasToken ? 'block' : 'none'; 102 | } 103 | 104 | if (showTokenPanel) { 105 | const panel = document.createElement('div'); 106 | panel.className = 'hg-token-panel'; 107 | const row = document.createElement('div'); 108 | row.className = 'hg-token-row'; 109 | const tokenInput = document.createElement('input'); 110 | tokenInput.type = 'password'; 111 | tokenInput.placeholder = hasToken ? 'Update token (ghp_...)' : 'Paste token (ghp_...)'; 112 | tokenInput.className = 'hg-input'; 113 | tokenInput.style.margin = '0'; 114 | const saveBtn = document.createElement('button'); 115 | saveBtn.textContent = 'Save'; 116 | saveBtn.className = 'hg-btn'; 117 | saveBtn.addEventListener('click', async () => { 118 | const token = tokenInput.value.trim(); 119 | if (!token) return; 120 | await chrome.runtime.sendMessage({ type: 'saveToken', token }); 121 | await refreshData(); 122 | showTokenPanel = false; 123 | render(); 124 | }); 125 | const clearBtn = document.createElement('button'); 126 | clearBtn.textContent = 'Clear'; 127 | clearBtn.className = 'hg-btn hg-btn-secondary'; 128 | clearBtn.addEventListener('click', async () => { 129 | await chrome.runtime.sendMessage({ type: 'clearToken' }); 130 | await refreshData(); 131 | showTokenPanel = false; 132 | render(); 133 | }); 134 | row.appendChild(tokenInput); 135 | row.appendChild(saveBtn); 136 | row.appendChild(clearBtn); 137 | panel.appendChild(row); 138 | list.appendChild(panel); 139 | } 140 | 141 | if (!hasToken && !showTokenPanel) { 142 | const wrap = document.createElement('div'); 143 | wrap.className = 'hg-empty'; 144 | wrap.innerHTML = '
Connect GitHub
' + 145 | '
Paste a GitHub Personal Access Token (PAT) with repo read access.
'; 146 | const tokenInput = document.createElement('input'); 147 | tokenInput.type = 'password'; 148 | tokenInput.placeholder = 'ghp_...'; 149 | tokenInput.className = 'hg-input'; 150 | tokenInput.style.marginTop = '8px'; 151 | const saveBtn = document.createElement('button'); 152 | saveBtn.textContent = 'Save Token'; 153 | saveBtn.className = 'hg-btn'; 154 | saveBtn.addEventListener('click', async () => { 155 | const token = tokenInput.value.trim(); 156 | if (!token) return; 157 | await chrome.runtime.sendMessage({ type: 'saveToken', token }); 158 | await refreshData(); 159 | hide(); 160 | }); 161 | wrap.appendChild(tokenInput); 162 | wrap.appendChild(saveBtn); 163 | list.appendChild(wrap); 164 | return; 165 | } 166 | 167 | if (filtered.length === 0) { 168 | const empty = document.createElement('div'); 169 | empty.className = 'hg-empty'; 170 | empty.textContent = 'No repositories. Type to search.'; 171 | list.appendChild(empty); 172 | return; 173 | } 174 | 175 | filtered.forEach((r, i) => { 176 | const item = document.createElement('div'); 177 | item.className = 'hg-item' + (i === selectedIndex ? ' selected' : ''); 178 | item.innerHTML = `
${escapeHtml(r.full_name)}
179 |
${escapeHtml(r.description || '')}
`; 180 | item.addEventListener('mouseenter', () => { selectedIndex = i; render(); }); 181 | item.addEventListener('click', () => { 182 | selectedIndex = i; 183 | openSelected(); 184 | }); 185 | list.appendChild(item); 186 | }); 187 | } 188 | 189 | function escapeHtml(str) { 190 | return str.replace(/[&<>"]+/g, s => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[s])); 191 | } 192 | 193 | function onInput() { 194 | filter(); 195 | selectedIndex = 0; 196 | render(); 197 | } 198 | 199 | function filter() { 200 | const q = input.value.trim().toLowerCase(); 201 | if (!q) { 202 | filtered = repos.slice(0, 50); 203 | return; 204 | } 205 | filtered = repos.filter(r => r.full_name.toLowerCase().includes(q) || (r.description || '').toLowerCase().includes(q)).slice(0, 50); 206 | } 207 | 208 | function isNavKey(key) { 209 | return key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Escape'; 210 | } 211 | 212 | function onKeyDown(e) { 213 | if (!visible) return; 214 | if (!isNavKey(e.key)) return; 215 | // Prevent page from hijacking navigation keys 216 | e.preventDefault(); 217 | e.stopPropagation(); 218 | if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); 219 | if (e.key === 'Escape') { 220 | hide(); 221 | return; 222 | } 223 | if (e.key === 'ArrowDown') { 224 | if (filtered.length) selectedIndex = (selectedIndex + 1) % filtered.length; 225 | render(); 226 | return; 227 | } 228 | if (e.key === 'ArrowUp') { 229 | if (filtered.length) selectedIndex = (selectedIndex - 1 + filtered.length) % filtered.length; 230 | render(); 231 | return; 232 | } 233 | if (e.key === 'Enter') { 234 | openSelected(); 235 | return; 236 | } 237 | } 238 | 239 | async function openSelected() { 240 | if (!hasToken) return; 241 | const sel = filtered[selectedIndex]; 242 | if (!sel) return; 243 | await chrome.runtime.sendMessage({ type: 'openRepo', url: sel.html_url, full_name: sel.full_name }); 244 | hide(); 245 | } 246 | 247 | async function refreshData(force = false) { 248 | const status = await chrome.runtime.sendMessage({ type: 'requestTokenStatus' }); 249 | hasToken = !!status?.hasToken; 250 | if (!hasToken) { 251 | repos = []; 252 | filtered = []; 253 | return; 254 | } 255 | const messageType = force ? 'refreshRepos' : 'getRepos'; 256 | const res = await chrome.runtime.sendMessage({ type: messageType }); 257 | repos = res?.repos || []; 258 | filtered = repos.slice(0, 50); 259 | } 260 | 261 | function show() { 262 | visible = true; 263 | selectedIndex = 0; 264 | overlay.style.display = 'block'; 265 | container.style.display = 'flex'; 266 | showTokenPanel = false; 267 | // Prevent page scrolling only 268 | document.body.style.overflow = 'hidden'; 269 | render(); 270 | input.focus(); 271 | } 272 | 273 | function hide() { 274 | visible = false; 275 | overlay.style.display = 'none'; 276 | container.style.display = 'none'; 277 | input.value = ''; 278 | filtered = repos.slice(0, 50); 279 | // Restore page scrolling 280 | document.body.style.overflow = ''; 281 | } 282 | 283 | chrome.runtime.onMessage.addListener((msg) => { 284 | if (msg?.type === 'togglePalette') { 285 | if (!paletteRoot) createUI(); 286 | refreshData().then(() => { render(); }); 287 | if (visible) hide(); else show(); 288 | } 289 | }); 290 | 291 | function injectBaseStyles() { 292 | const style = document.createElement('style'); 293 | style.textContent = ` 294 | .hg-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); backdrop-filter: saturate(1.2) blur(2px); z-index: 2147483646; display: none; } 295 | .hg-container, .hg-container * { box-sizing: border-box; } 296 | .hg-container { position: fixed; top: 6%; left: 50%; transform: translateX(-50%); width: min(800px, 92vw); max-height: 90vh; background: #111; color: #eee; border: 1px solid #333; border-radius: 12px; box-shadow: 0 18px 60px rgba(0,0,0,0.55); z-index: 2147483647; display: none; font-family: ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden; display: flex; flex-direction: column; } 297 | .hg-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; color: #aaa; border-bottom: 1px solid #222; flex: 0 0 auto; } 298 | .hg-header-title { font-weight: 600; } 299 | .hg-header-actions { display: flex; gap: 8px; } 300 | .hg-icon-btn { background: #1a1a1a; color: #ddd; border: 1px solid #2a2a2a; border-radius: 8px; padding: 6px 10px; cursor: pointer; } 301 | .hg-icon-btn:hover { background: #222; } 302 | .hg-input { width: calc(100% - 24px); margin: 12px; padding: 10px 12px; background: #0b0b0b; color: #eee; border: 1px solid #222; border-radius: 8px; outline: none; flex: 0 0 auto; } 303 | .hg-list { padding: 8px 6px 10px 6px; overflow: auto; flex: 1 1 auto; min-height: 0; } 304 | .hg-item { padding: 10px 10px; border-radius: 8px; cursor: pointer; } 305 | .hg-item:hover { background: #161616; } 306 | .hg-item.selected { background: #1d1d1d; } 307 | .hg-item-title { font-size: 14px; color: #eaeaea; } 308 | .hg-item-desc { font-size: 12px; color: #9aa0a6; margin-top: 4px; } 309 | .hg-empty { padding: 16px; text-align: center; color: #9aa0a6; } 310 | .hg-btn { margin-left: 8px; padding: 8px 12px; background: #2d6cdf; color: white; border: none; border-radius: 8px; cursor: pointer; } 311 | .hg-btn-secondary { background: #2a2a2a; color: #ddd; } 312 | .hg-token-panel { padding: 10px 10px 2px 10px; } 313 | .hg-token-row { display: flex; gap: 8px; align-items: center; } 314 | .hg-token-row .hg-input { flex: 1; } 315 | `; 316 | shadow.appendChild(style); 317 | } 318 | })(); 319 | --------------------------------------------------------------------------------