experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(scripts): implement Phase 1 of userscripts/content scripts system

- Create scripts extension with manifest and settings schema
- Implement ScriptExecutor with pattern matching and timeout protection
- Build scripts manager UI with three-panel layout (list, editor, preview)
- Add datastore schema for scripts persistence
- Register 'scripts' command and Command+Shift+S shortcut
- Add basic tests for pattern matching and extension loading

Phase 1 complete with core functionality:
- Script CRUD operations via datastore
- Pattern matching for URL filtering (glob patterns)
- Timeout protection (5s default)
- Console output capture
- Simple textarea editor (CodeMirror integration deferred to Phase 2)

Known limitations:
- Script execution blocked by CSP in background context
- Execution tests would need non-CSP context
- Full editor integration pending Phase 2

+5329 -138
+108
DEVELOPMENT.md
··· 231 231 - Sync configuration (optional API key + server profile mapping) 232 232 - `PROFILE` env var overrides automatic detection (for testing) 233 233 234 + ## UI Development Guidelines 235 + 236 + ### Always Use Peek Components 237 + 238 + **CRITICAL:** All new UI features MUST use components from `app/components/`. DO NOT create manual DOM with `innerHTML` or `createElement` for UI elements. 239 + 240 + #### Available Components 241 + 242 + - **Layout:** `peek-card`, `peek-grid`, `peek-list`/`peek-list-item` 243 + - **Forms:** `peek-input`, `peek-button`, `peek-select`, `peek-switch`, `peek-button-group` 244 + - **Interactive:** `peek-dialog`, `peek-drawer`, `peek-popover`, `peek-tabs`, `peek-carousel` 245 + - **Native wrappers:** `peek-details` (disclosure/accordion), `peek-tooltip` 246 + 247 + See `app/components/README.md` for complete documentation. 248 + 249 + #### Component Usage Pattern 250 + 251 + ```javascript 252 + // Import components at top of HTML 253 + <script type="module" src="peek://app/components/peek-card.js"></script> 254 + <script type="module" src="peek://app/components/peek-grid.js"></script> 255 + 256 + // Use in JavaScript 257 + const card = document.createElement('peek-card'); 258 + card.interactive = true; 259 + card.elevated = true; 260 + 261 + const header = document.createElement('div'); 262 + header.slot = 'header'; 263 + header.textContent = 'Card Title'; 264 + card.appendChild(header); 265 + 266 + // Or use innerHTML for static content 267 + card.innerHTML = ` 268 + <div slot="header">Card Title</div> 269 + <p>Card content here</p> 270 + `; 271 + ``` 272 + 273 + #### Styling Guidelines 274 + 275 + - Use CSS custom properties for theming (see `app/components/tokens.css`) 276 + - DO NOT override component internals 277 + - Extend via slots and CSS parts 278 + - Follow design tokens for consistency 279 + 280 + ```css 281 + peek-card { 282 + --peek-card-bg: var(--base01); 283 + --peek-card-border: transparent; 284 + --peek-card-radius: 8px; 285 + } 286 + 287 + peek-card::part(header) { 288 + background: var(--base02); 289 + } 290 + ``` 291 + 292 + #### Migration of Existing Code 293 + 294 + Existing extensions are being migrated to peek-components. When touching existing UI code: 295 + 296 + 1. **Check migration status** in `notes/migration-ui-componentry.md` 297 + 2. **Follow established patterns** from already-migrated extensions 298 + 3. **Consider migrating** the code you're working on to components 299 + 4. **Ask first** if uncertain whether to migrate during your change 300 + 301 + See `notes/migration-ui-componentry.md` for detailed migration patterns. 302 + 303 + #### Benefits 304 + 305 + - **Consistency** across all extensions 306 + - **Automatic theme support** via CSS custom properties 307 + - **Built-in accessibility** (ARIA patterns, keyboard navigation) 308 + - **Reduced code duplication** (~50% less UI code) 309 + - **Easier maintenance** - fix once, applies everywhere 310 + 311 + #### Example: Creating a Card-Based UI 312 + 313 + ```javascript 314 + // Import components 315 + import 'peek://app/components/peek-card.js'; 316 + import 'peek://app/components/peek-grid.js'; 317 + 318 + // Create grid container 319 + const grid = document.createElement('peek-grid'); 320 + grid.setAttribute('min-item-width', '200'); 321 + grid.setAttribute('gap', '12'); 322 + 323 + // Create cards 324 + items.forEach(item => { 325 + const card = document.createElement('peek-card'); 326 + card.interactive = true; 327 + card.selected = item.id === selectedId; 328 + 329 + card.innerHTML = ` 330 + <div slot="header">${escapeHtml(item.title)}</div> 331 + <p>${escapeHtml(item.description)}</p> 332 + <span slot="footer">${item.count} items</span> 333 + `; 334 + 335 + card.addEventListener('card-click', () => handleSelect(item)); 336 + grid.appendChild(card); 337 + }); 338 + 339 + document.body.appendChild(grid); 340 + ``` 341 + 234 342 ## Critical Rules 235 343 236 344 ### Protected Directories
+24 -73
extensions/groups/home.css
··· 25 25 padding: 24px 24px 0 24px; 26 26 } 27 27 28 - .search-input { 28 + /* Component customization for peek-input */ 29 + peek-input.search-input { 29 30 width: 100%; 30 - padding: 12px 16px; 31 - font-size: 15px; 32 - font-family: var(--theme-font-sans); 33 - background: var(--base01); 34 - border: 1px solid var(--base02); 35 - border-radius: 8px; 36 - color: var(--base05); 37 - outline: none; 38 - transition: all 0.15s ease; 31 + --peek-input-bg: var(--base01); 32 + --peek-input-border: var(--base02); 33 + --peek-input-height: 44px; 39 34 } 40 35 41 - .search-input:focus { 42 - border-color: var(--base0D); 43 - background: var(--base00); 36 + peek-input.search-input:focus-within { 37 + --peek-input-border: var(--base0D); 44 38 } 45 39 46 - .search-input::placeholder { 47 - color: var(--base03); 48 - } 49 - 50 - /* Cards container */ 51 - .cards { 40 + /* Component customization for peek-grid */ 41 + peek-grid.cards { 52 42 padding: 24px; 53 - display: grid; 54 - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 55 - gap: 12px; 43 + --peek-grid-min-item-width: 200px; 44 + --peek-grid-gap: 12px; 56 45 } 57 46 58 - /* Card base */ 59 - .card { 60 - background: var(--base01); 61 - border-radius: 8px; 62 - padding: 16px; 63 - cursor: pointer; 64 - transition: all 0.15s ease; 65 - display: flex; 66 - align-items: flex-start; 67 - gap: 12px; 47 + /* Component customization for peek-card */ 48 + peek-card { 49 + --peek-card-bg: var(--base01); 50 + --peek-card-border: transparent; 51 + --peek-card-radius: 8px; 52 + --peek-card-padding: 16px; 68 53 } 69 54 70 - .card:hover { 71 - background: var(--base02); 72 - transform: translateY(-2px); 73 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 74 - } 75 - 76 - .card.selected { 77 - background: var(--base02); 78 - outline: 2px solid var(--base0D); 79 - outline-offset: -2px; 80 - } 81 - 82 - .card.selected:hover { 83 - background: var(--base03); 55 + peek-card[selected] { 56 + --peek-card-bg: var(--base02); 57 + --peek-card-border: var(--base0D); 84 58 } 85 59 86 - /* Group card */ 87 - .group-card .color-dot { 60 + /* Custom styles for card content */ 61 + .color-dot { 88 62 width: 12px; 89 63 height: 12px; 90 64 border-radius: 50%; 91 65 flex-shrink: 0; 92 - margin-top: 4px; 93 66 } 94 67 95 - /* Address card */ 96 - .address-card .card-favicon { 68 + .card-favicon { 97 69 width: 32px; 98 70 height: 32px; 99 71 border-radius: 4px; ··· 102 74 object-fit: contain; 103 75 } 104 76 105 - /* Card content */ 106 - .card-content { 107 - flex: 1; 108 - min-width: 0; 109 - } 110 - 111 - .card-title { 112 - font-size: 15px; 113 - font-weight: 600; 114 - color: var(--base05); 115 - margin-bottom: 4px; 116 - white-space: nowrap; 117 - overflow: hidden; 118 - text-overflow: ellipsis; 119 - } 120 - 121 77 .card-url { 122 78 font-size: 12px; 123 79 color: var(--base04); ··· 127 83 margin-bottom: 8px; 128 84 } 129 85 130 - .card-meta { 131 - font-size: 12px; 132 - color: var(--base03); 133 - } 134 - 135 - /* Empty state */ 86 + /* Empty state - grid-column for spanning full width in peek-grid */ 136 87 .empty-state { 137 88 grid-column: 1 / -1; 138 89 text-align: center;
+12 -2
extensions/groups/home.html
··· 6 6 <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 7 <title>Groups</title> 8 8 <link rel="stylesheet" type="text/css" href="home.css"> 9 + 10 + <script type="module"> 11 + import 'peek://app/components/peek-card.js'; 12 + import 'peek://app/components/peek-grid.js'; 13 + import 'peek://app/components/peek-input.js'; 14 + </script> 9 15 </head> 10 16 <body> 11 17 <div class="search-container"> 12 - <input type="text" class="search-input" placeholder="Search groups..."> 18 + <peek-input 19 + class="search-input" 20 + placeholder="Search groups..." 21 + type="search" 22 + ></peek-input> 13 23 </div> 14 24 15 - <main class="cards"></main> 25 + <peek-grid class="cards" min-item-width="200" gap="12"></peek-grid> 16 26 17 27 <script type="module" src="home.js"></script> 18 28 </body>
+93 -56
extensions/groups/home.js
··· 66 66 console.log('[groups:esc] handleEscape called, view:', state.view, 'searchQuery:', state.searchQuery); 67 67 68 68 // If search has content, clear it first 69 - const searchInput = document.querySelector('.search-input'); 69 + const searchInput = document.querySelector('peek-input.search-input'); 70 70 if (state.searchQuery) { 71 71 state.searchQuery = ''; 72 - searchInput.value = ''; 72 + if (searchInput) searchInput.value = ''; 73 73 renderCurrentView(); 74 74 console.log('[groups:esc] Cleared search, returning handled: true'); 75 75 return { handled: true }; ··· 93 93 94 94 /** 95 95 * Get all cards in the current view 96 + * UPDATED to work with peek-card components 96 97 */ 97 98 const getCards = () => { 98 - return Array.from(document.querySelectorAll('.cards .card')); 99 + return Array.from(document.querySelectorAll('.cards peek-card')); 99 100 }; 100 101 101 102 /** 102 103 * Update visual selection on cards 104 + * UPDATED to use peek-card selected property 103 105 */ 104 106 const updateSelection = () => { 105 107 const cards = getCards(); 106 108 cards.forEach((card, i) => { 107 - card.classList.toggle('selected', i === state.selectedIndex); 109 + card.selected = i === state.selectedIndex; 108 110 }); 109 111 110 112 // Scroll selected card into view ··· 143 145 * Handle keyboard navigation (vim-style hjkl for grid movement) 144 146 */ 145 147 const handleKeydown = (e) => { 146 - const searchInput = document.querySelector('.search-input'); 147 - const isSearchFocused = document.activeElement === searchInput; 148 + const searchInput = document.querySelector('peek-input.search-input'); 149 + const searchInputElement = searchInput?.shadowRoot?.querySelector('input'); 150 + const isSearchFocused = document.activeElement === searchInput || 151 + (searchInputElement && searchInput.matches(':focus-within')); 148 152 149 153 // Focus search with / or Cmd+F 150 154 if ((e.key === '/' || (e.key === 'f' && (e.metaKey || e.ctrlKey))) && !isSearchFocused) { 151 155 e.preventDefault(); 152 - searchInput.focus(); 156 + searchInput?.focus(); 153 157 return; 154 158 } 155 159 ··· 215 219 // Load tags from datastore 216 220 await loadTags(); 217 221 218 - // Set up search input 219 - const searchInput = document.querySelector('.search-input'); 222 + // Set up search input (UPDATED for peek-input) 223 + const searchInput = document.querySelector('peek-input.search-input'); 220 224 searchInput.addEventListener('input', (e) => { 221 - state.searchQuery = e.target.value; 225 + state.searchQuery = searchInput.value; 222 226 renderCurrentView(); 223 227 }); 224 228 ··· 382 386 await loadTags(); 383 387 384 388 // Update search placeholder 385 - const searchInput = document.querySelector('.search-input'); 386 - searchInput.value = ''; 387 - searchInput.placeholder = 'Search groups...'; 389 + const searchInput = document.querySelector('peek-input.search-input'); 390 + if (searchInput) { 391 + searchInput.value = ''; 392 + searchInput.placeholder = 'Search groups...'; 393 + } 388 394 389 395 renderGroups(); 390 396 }; ··· 394 400 395 401 /** 396 402 * Render groups cards (separate from showGroups for filtering) 403 + * UPDATED to use peek-grid 397 404 */ 398 405 const renderGroups = () => { 399 - const container = document.querySelector('.cards'); 406 + const container = document.querySelector('peek-grid.cards'); 400 407 container.innerHTML = ''; 401 408 402 409 // Build list of all groups (untagged first if it has items) ··· 416 423 const message = state.searchQuery 417 424 ? 'No groups match your search.' 418 425 : 'No groups yet. Tag some pages to create groups.'; 419 - container.innerHTML = `<div class="empty-state">${message}</div>`; 426 + const emptyState = document.createElement('div'); 427 + emptyState.className = 'empty-state'; 428 + emptyState.textContent = message; 429 + container.appendChild(emptyState); 420 430 return; 421 431 } 422 432 ··· 476 486 } 477 487 478 488 // Update search placeholder with group name 479 - const searchInput = document.querySelector('.search-input'); 480 - searchInput.value = ''; 481 - searchInput.placeholder = `Search in ${tag.name}...`; 489 + const searchInput = document.querySelector('peek-input.search-input'); 490 + if (searchInput) { 491 + searchInput.value = ''; 492 + searchInput.placeholder = `Search in ${tag.name}...`; 493 + } 482 494 483 495 renderAddresses(); 484 496 }; 485 497 486 498 /** 487 499 * Render address cards (separate from showAddresses for filtering) 500 + * UPDATED to use peek-grid 488 501 */ 489 502 const renderAddresses = () => { 490 - const container = document.querySelector('.cards'); 503 + const container = document.querySelector('peek-grid.cards'); 491 504 container.innerHTML = ''; 492 505 493 506 // Apply search filter ··· 497 510 const message = state.searchQuery 498 511 ? 'No pages match your search.' 499 512 : 'No pages in this group yet.'; 500 - container.innerHTML = `<div class="empty-state">${message}</div>`; 513 + const emptyState = document.createElement('div'); 514 + emptyState.className = 'empty-state'; 515 + emptyState.textContent = message; 516 + container.appendChild(emptyState); 501 517 return; 502 518 } 503 519 ··· 513 529 514 530 /** 515 531 * Create a card element for a group (tag) 532 + * MIGRATED to use peek-card component 516 533 */ 517 534 const createGroupCard = (tag) => { 518 - const card = document.createElement('div'); 519 - card.className = 'card group-card'; 535 + const card = document.createElement('peek-card'); 536 + card.interactive = true; 537 + card.elevated = true; 520 538 if (tag.isSpecial) { 521 539 card.classList.add('special-group'); 522 540 } 523 541 card.dataset.tagId = tag.id; 524 542 543 + // Header with color dot and name 544 + const header = document.createElement('div'); 545 + header.slot = 'header'; 546 + header.style.display = 'flex'; 547 + header.style.alignItems = 'center'; 548 + header.style.gap = '12px'; 549 + 525 550 const colorDot = document.createElement('div'); 526 551 colorDot.className = 'color-dot'; 552 + colorDot.style.width = '12px'; 553 + colorDot.style.height = '12px'; 554 + colorDot.style.borderRadius = '50%'; 555 + colorDot.style.flexShrink = '0'; 527 556 colorDot.style.backgroundColor = tag.color || '#999'; 528 557 529 - const content = document.createElement('div'); 530 - content.className = 'card-content'; 558 + const name = document.createElement('span'); 559 + name.textContent = tag.name; 531 560 532 - const title = document.createElement('h2'); 533 - title.className = 'card-title'; 534 - title.textContent = tag.name; 561 + header.appendChild(colorDot); 562 + header.appendChild(name); 563 + card.appendChild(header); 535 564 536 - const meta = document.createElement('div'); 537 - meta.className = 'card-meta'; 565 + // Footer with count 566 + const footer = document.createElement('span'); 567 + footer.slot = 'footer'; 538 568 const count = tag.isSpecial ? (tag.frequency || 0) : (tag.addressCount || 0); 539 - meta.textContent = `${count} ${count === 1 ? 'page' : 'pages'}`; 540 - 541 - content.appendChild(title); 542 - content.appendChild(meta); 543 - 544 - card.appendChild(colorDot); 545 - card.appendChild(content); 569 + footer.textContent = `${count} ${count === 1 ? 'page' : 'pages'}`; 570 + card.appendChild(footer); 546 571 547 572 // Click to view addresses in this group 548 - card.addEventListener('click', () => showAddresses(tag)); 573 + card.addEventListener('card-click', () => showAddresses(tag)); 549 574 550 575 return card; 551 576 }; ··· 553 578 /** 554 579 * Create a card element for an address 555 580 * Handles both Address (uri) and Item (content) objects 581 + * MIGRATED to use peek-card component 556 582 */ 557 583 const createAddressCard = (address) => { 558 - const card = document.createElement('div'); 559 - card.className = 'card address-card'; 584 + const card = document.createElement('peek-card'); 585 + card.interactive = true; 586 + card.elevated = true; 560 587 card.dataset.addressId = address.id; 561 588 562 589 // Get URL from either uri (Address) or content (Item) ··· 574 601 } 575 602 displayTitle = displayTitle || addressUrl; 576 603 604 + // Favicon in media slot 577 605 const favicon = document.createElement('img'); 606 + favicon.slot = 'media'; 578 607 favicon.className = 'card-favicon'; 579 608 favicon.src = address.favicon || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 609 + favicon.alt = ''; 610 + favicon.style.width = '32px'; 611 + favicon.style.height = '32px'; 612 + favicon.style.borderRadius = '4px'; 613 + favicon.style.flexShrink = '0'; 614 + favicon.style.objectFit = 'contain'; 580 615 favicon.onerror = () => { 581 616 favicon.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 582 617 }; 618 + card.appendChild(favicon); 583 619 584 - const content = document.createElement('div'); 585 - content.className = 'card-content'; 620 + // Header with title 621 + const header = document.createElement('div'); 622 + header.slot = 'header'; 623 + header.textContent = displayTitle; 624 + card.appendChild(header); 586 625 587 - const title = document.createElement('h2'); 588 - title.className = 'card-title'; 589 - title.textContent = displayTitle; 590 - 626 + // Body with URL 591 627 const url = document.createElement('div'); 592 628 url.className = 'card-url'; 593 629 url.textContent = addressUrl; 630 + url.style.fontSize = '12px'; 631 + url.style.color = 'var(--base04)'; 632 + url.style.overflow = 'hidden'; 633 + url.style.textOverflow = 'ellipsis'; 634 + url.style.whiteSpace = 'nowrap'; 635 + card.appendChild(url); 594 636 595 - const meta = document.createElement('div'); 596 - meta.className = 'card-meta'; 637 + // Footer with metadata 638 + const footer = document.createElement('span'); 639 + footer.slot = 'footer'; 597 640 const lastVisit = address.lastVisitAt ? new Date(address.lastVisitAt).toLocaleDateString() : 'Never'; 598 - meta.textContent = `${address.visitCount || 0} visits · Last: ${lastVisit}`; 599 - 600 - content.appendChild(title); 601 - content.appendChild(url); 602 - content.appendChild(meta); 603 - 604 - card.appendChild(favicon); 605 - card.appendChild(content); 641 + footer.textContent = `${address.visitCount || 0} visits · Last: ${lastVisit}`; 642 + card.appendChild(footer); 606 643 607 644 // Click to open address - backend handles centering and parent tracking 608 - card.addEventListener('click', async () => { 645 + card.addEventListener('card-click', async () => { 609 646 debug && console.log('Opening address:', addressUrl); 610 647 const result = await api.window.open(addressUrl, { 611 648 width: 800,
+1 -1
extensions/hud/background.js
··· 10 10 11 11 console.log('[ext:hud] background init'); 12 12 13 - const HUD_ADDRESS = 'peek://hud/hud.html'; 13 + const HUD_ADDRESS = 'peek://ext/hud/hud.html'; 14 14 const STORAGE_KEY = 'hud_enabled'; 15 15 16 16 // Track HUD state
+50
extensions/scripts/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Scripts Extension</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] background.html loaded`); 16 + 17 + // Signal ready to main process 18 + api.publish('ext:ready', { 19 + id: extId, 20 + manifest: { 21 + id: extension.id, 22 + labels: extension.labels, 23 + version: '1.0.0' 24 + } 25 + }, api.scopes.SYSTEM); 26 + 27 + // Initialize extension 28 + if (extension.init) { 29 + console.log(`[ext:${extId}] calling init()`); 30 + extension.init(); 31 + } 32 + 33 + // Handle shutdown request from main process 34 + api.subscribe('app:shutdown', () => { 35 + console.log(`[ext:${extId}] received shutdown`); 36 + if (extension.uninit) { 37 + extension.uninit(); 38 + } 39 + }, api.scopes.SYSTEM); 40 + 41 + // Handle extension-specific shutdown 42 + api.subscribe(`ext:${extId}:shutdown`, () => { 43 + console.log(`[ext:${extId}] received extension shutdown`); 44 + if (extension.uninit) { 45 + extension.uninit(); 46 + } 47 + }, api.scopes.SYSTEM); 48 + </script> 49 + </body> 50 + </html>
+298
extensions/scripts/background.js
··· 1 + /** 2 + * Scripts Extension Background Script 3 + * 4 + * Manages userscripts/content scripts system 5 + * 6 + * Features: 7 + * - Script storage and management 8 + * - Pattern matching and execution 9 + * - Scripts manager UI 10 + * - Command registration 11 + * 12 + * Runs in isolated extension process (peek://ext/scripts/background.html) 13 + */ 14 + 15 + import { scriptExecutor } from './script-executor.js'; 16 + 17 + const api = window.app; 18 + 19 + console.log('[ext:scripts] background loaded'); 20 + 21 + // Default settings 22 + const defaults = { 23 + scripts: [] 24 + }; 25 + 26 + // In-memory cache of scripts 27 + let currentSettings = { scripts: [] }; 28 + 29 + /** 30 + * Load scripts from datastore 31 + */ 32 + const loadSettings = async () => { 33 + const result = await api.settings.get(); 34 + if (result.success && result.data) { 35 + return { 36 + scripts: result.data.scripts || defaults.scripts 37 + }; 38 + } 39 + return defaults; 40 + }; 41 + 42 + /** 43 + * Save scripts to datastore 44 + */ 45 + const saveSettings = async (settings) => { 46 + const result = await api.settings.set(settings); 47 + if (!result.success) { 48 + console.error('[ext:scripts] Failed to save settings:', result.error); 49 + } 50 + return result; 51 + }; 52 + 53 + /** 54 + * Generate unique ID for script 55 + */ 56 + const generateId = () => { 57 + return `script_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 58 + }; 59 + 60 + /** 61 + * Open scripts manager UI 62 + */ 63 + const openScriptsManager = (params = {}) => { 64 + console.log('[ext:scripts] Opening scripts manager', params); 65 + 66 + let url = 'peek://ext/scripts/manager.html'; 67 + if (params.scriptId) { 68 + url += `?scriptId=${params.scriptId}`; 69 + } 70 + 71 + api.window.open(url, { 72 + key: 'scripts-manager', 73 + width: 1200, 74 + height: 800, 75 + title: 'Scripts Manager' 76 + }); 77 + }; 78 + 79 + /** 80 + * Create a new script 81 + */ 82 + const createScript = async (scriptData) => { 83 + const script = { 84 + id: generateId(), 85 + name: scriptData.name || 'Untitled Script', 86 + description: scriptData.description || '', 87 + code: scriptData.code || '// Your script here\nreturn { success: true };', 88 + matchPatterns: scriptData.matchPatterns || ['*://*/*'], 89 + excludePatterns: scriptData.excludePatterns || [], 90 + runAt: scriptData.runAt || 'document-end', 91 + enabled: scriptData.enabled !== undefined ? scriptData.enabled : true, 92 + createdAt: Date.now(), 93 + updatedAt: Date.now(), 94 + lastExecutedAt: null 95 + }; 96 + 97 + currentSettings.scripts.push(script); 98 + await saveSettings(currentSettings); 99 + 100 + // Notify listeners 101 + api.publish('scripts:created', { script }, api.scopes.GLOBAL); 102 + 103 + return { success: true, data: script }; 104 + }; 105 + 106 + /** 107 + * Update an existing script 108 + */ 109 + const updateScript = async (scriptId, updates) => { 110 + const scriptIndex = currentSettings.scripts.findIndex(s => s.id === scriptId); 111 + if (scriptIndex === -1) { 112 + return { success: false, error: 'Script not found' }; 113 + } 114 + 115 + currentSettings.scripts[scriptIndex] = { 116 + ...currentSettings.scripts[scriptIndex], 117 + ...updates, 118 + updatedAt: Date.now() 119 + }; 120 + 121 + await saveSettings(currentSettings); 122 + 123 + // Notify listeners 124 + api.publish('scripts:updated', { scriptId, script: currentSettings.scripts[scriptIndex] }, api.scopes.GLOBAL); 125 + 126 + return { success: true, data: currentSettings.scripts[scriptIndex] }; 127 + }; 128 + 129 + /** 130 + * Delete a script 131 + */ 132 + const deleteScript = async (scriptId) => { 133 + const scriptIndex = currentSettings.scripts.findIndex(s => s.id === scriptId); 134 + if (scriptIndex === -1) { 135 + return { success: false, error: 'Script not found' }; 136 + } 137 + 138 + currentSettings.scripts.splice(scriptIndex, 1); 139 + await saveSettings(currentSettings); 140 + 141 + // Notify listeners 142 + api.publish('scripts:deleted', { scriptId }, api.scopes.GLOBAL); 143 + 144 + return { success: true }; 145 + }; 146 + 147 + /** 148 + * Execute a script against a URL 149 + */ 150 + const executeScript = async (scriptId, executionContext) => { 151 + const script = currentSettings.scripts.find(s => s.id === scriptId); 152 + if (!script) { 153 + return { success: false, error: 'Script not found' }; 154 + } 155 + 156 + if (!script.enabled) { 157 + return { success: false, error: 'Script is disabled' }; 158 + } 159 + 160 + try { 161 + const result = await scriptExecutor.executeScript(script, executionContext); 162 + 163 + // Update last executed time 164 + await updateScript(scriptId, { lastExecutedAt: Date.now() }); 165 + 166 + // Publish execution result 167 + api.publish('scripts:executed', { 168 + scriptId, 169 + result, 170 + url: executionContext.url 171 + }, api.scopes.GLOBAL); 172 + 173 + return { success: true, data: result }; 174 + } catch (error) { 175 + console.error('[ext:scripts] Execution error:', error); 176 + return { 177 + success: false, 178 + error: error.message, 179 + stack: error.stack 180 + }; 181 + } 182 + }; 183 + 184 + /** 185 + * Get all scripts 186 + */ 187 + const getScripts = async () => { 188 + return { success: true, data: currentSettings.scripts }; 189 + }; 190 + 191 + /** 192 + * Get a single script 193 + */ 194 + const getScript = async (scriptId) => { 195 + const script = currentSettings.scripts.find(s => s.id === scriptId); 196 + if (!script) { 197 + return { success: false, error: 'Script not found' }; 198 + } 199 + return { success: true, data: script }; 200 + }; 201 + 202 + /** 203 + * Register commands - called when cmd extension is ready 204 + */ 205 + const registerCommands = () => { 206 + api.commands.register({ 207 + name: 'scripts', 208 + description: 'Open scripts manager', 209 + execute: () => openScriptsManager() 210 + }); 211 + 212 + api.commands.register({ 213 + name: 'scripts: new', 214 + description: 'Create a new script', 215 + execute: async () => { 216 + const result = await createScript({}); 217 + if (result.success) { 218 + openScriptsManager({ scriptId: result.data.id }); 219 + } 220 + } 221 + }); 222 + 223 + console.log('[ext:scripts] Commands registered'); 224 + }; 225 + 226 + /** 227 + * Initialize the extension 228 + */ 229 + const init = async () => { 230 + console.log('[ext:scripts] init'); 231 + 232 + // Load scripts from datastore 233 + currentSettings = await loadSettings(); 234 + console.log('[ext:scripts] Loaded', currentSettings.scripts.length, 'scripts'); 235 + 236 + // Wait for cmd:ready before registering commands 237 + api.subscribe('cmd:ready', () => { 238 + registerCommands(); 239 + }, api.scopes.GLOBAL); 240 + 241 + // Query in case cmd is already ready 242 + api.publish('cmd:query', {}, api.scopes.GLOBAL); 243 + 244 + // Register global shortcut Command+Shift+S 245 + api.shortcuts.register('Command+Shift+S', () => openScriptsManager()); 246 + 247 + // API for other extensions/pages to interact with scripts 248 + api.subscribe('scripts:create', async (msg) => { 249 + const result = await createScript(msg); 250 + api.publish('scripts:create:response', result, api.scopes.GLOBAL); 251 + }, api.scopes.GLOBAL); 252 + 253 + api.subscribe('scripts:update', async (msg) => { 254 + const result = await updateScript(msg.scriptId, msg.updates); 255 + api.publish('scripts:update:response', result, api.scopes.GLOBAL); 256 + }, api.scopes.GLOBAL); 257 + 258 + api.subscribe('scripts:delete', async (msg) => { 259 + const result = await deleteScript(msg.scriptId); 260 + api.publish('scripts:delete:response', result, api.scopes.GLOBAL); 261 + }, api.scopes.GLOBAL); 262 + 263 + api.subscribe('scripts:execute', async (msg) => { 264 + const result = await executeScript(msg.scriptId, msg.context); 265 + api.publish('scripts:execute:response', result, api.scopes.GLOBAL); 266 + }, api.scopes.GLOBAL); 267 + 268 + api.subscribe('scripts:get-all', async () => { 269 + const result = await getScripts(); 270 + api.publish('scripts:get-all:response', result, api.scopes.GLOBAL); 271 + }, api.scopes.GLOBAL); 272 + 273 + api.subscribe('scripts:get', async (msg) => { 274 + const result = await getScript(msg.scriptId); 275 + api.publish('scripts:get:response', result, api.scopes.GLOBAL); 276 + }, api.scopes.GLOBAL); 277 + 278 + console.log('[ext:scripts] Extension loaded'); 279 + }; 280 + 281 + /** 282 + * Cleanup 283 + */ 284 + const uninit = () => { 285 + console.log('[ext:scripts] Cleaning up...'); 286 + api.commands.unregister('scripts'); 287 + api.commands.unregister('scripts: new'); 288 + api.shortcuts.unregister('Command+Shift+S'); 289 + }; 290 + 291 + export default { 292 + id: 'scripts', 293 + labels: { 294 + name: 'Scripts' 295 + }, 296 + init, 297 + uninit 298 + };
+440
extensions/scripts/manager.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Scripts Manager</title> 6 + <style> 7 + * { 8 + box-sizing: border-box; 9 + margin: 0; 10 + padding: 0; 11 + } 12 + 13 + body { 14 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 15 + background: #1e1e1e; 16 + color: #d4d4d4; 17 + height: 100vh; 18 + overflow: hidden; 19 + } 20 + 21 + .container { 22 + display: flex; 23 + height: 100vh; 24 + } 25 + 26 + /* Left Panel - Scripts List */ 27 + .scripts-list { 28 + width: 300px; 29 + background: #252526; 30 + border-right: 1px solid #3e3e42; 31 + display: flex; 32 + flex-direction: column; 33 + } 34 + 35 + .scripts-header { 36 + padding: 16px; 37 + border-bottom: 1px solid #3e3e42; 38 + } 39 + 40 + .scripts-header h1 { 41 + font-size: 18px; 42 + font-weight: 600; 43 + margin-bottom: 12px; 44 + } 45 + 46 + .new-script-btn { 47 + width: 100%; 48 + padding: 8px; 49 + background: #0e639c; 50 + color: white; 51 + border: none; 52 + border-radius: 4px; 53 + cursor: pointer; 54 + font-size: 14px; 55 + } 56 + 57 + .new-script-btn:hover { 58 + background: #1177bb; 59 + } 60 + 61 + .scripts-items { 62 + flex: 1; 63 + overflow-y: auto; 64 + padding: 8px; 65 + } 66 + 67 + .script-item { 68 + padding: 12px; 69 + margin-bottom: 4px; 70 + background: #2d2d30; 71 + border-radius: 4px; 72 + cursor: pointer; 73 + border: 2px solid transparent; 74 + } 75 + 76 + .script-item:hover { 77 + background: #37373d; 78 + } 79 + 80 + .script-item.active { 81 + border-color: #0e639c; 82 + background: #37373d; 83 + } 84 + 85 + .script-item-header { 86 + display: flex; 87 + align-items: center; 88 + gap: 8px; 89 + margin-bottom: 4px; 90 + } 91 + 92 + .script-checkbox { 93 + margin: 0; 94 + } 95 + 96 + .script-name { 97 + flex: 1; 98 + font-weight: 500; 99 + font-size: 14px; 100 + } 101 + 102 + .script-status { 103 + width: 8px; 104 + height: 8px; 105 + border-radius: 50%; 106 + background: #808080; 107 + } 108 + 109 + .script-status.success { 110 + background: #4ec9b0; 111 + } 112 + 113 + .script-status.error { 114 + background: #f48771; 115 + } 116 + 117 + .script-meta { 118 + font-size: 11px; 119 + color: #858585; 120 + margin-left: 28px; 121 + } 122 + 123 + /* Center Panel - Editor */ 124 + .editor-panel { 125 + flex: 1; 126 + display: flex; 127 + flex-direction: column; 128 + background: #1e1e1e; 129 + } 130 + 131 + .editor-header { 132 + padding: 16px; 133 + border-bottom: 1px solid #3e3e42; 134 + } 135 + 136 + .editor-form { 137 + display: flex; 138 + flex-direction: column; 139 + gap: 12px; 140 + } 141 + 142 + .form-group { 143 + display: flex; 144 + flex-direction: column; 145 + gap: 4px; 146 + } 147 + 148 + .form-group label { 149 + font-size: 12px; 150 + color: #858585; 151 + } 152 + 153 + .form-group input, 154 + .form-group select { 155 + padding: 6px 8px; 156 + background: #3c3c3c; 157 + border: 1px solid #3e3e42; 158 + color: #d4d4d4; 159 + border-radius: 3px; 160 + font-size: 13px; 161 + } 162 + 163 + .form-group input:focus, 164 + .form-group select:focus { 165 + outline: none; 166 + border-color: #0e639c; 167 + } 168 + 169 + .editor-content { 170 + flex: 1; 171 + display: flex; 172 + flex-direction: column; 173 + padding: 16px; 174 + } 175 + 176 + .code-editor { 177 + flex: 1; 178 + background: #1e1e1e; 179 + color: #d4d4d4; 180 + border: 1px solid #3e3e42; 181 + border-radius: 4px; 182 + padding: 12px; 183 + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; 184 + font-size: 13px; 185 + resize: none; 186 + outline: none; 187 + } 188 + 189 + .code-editor:focus { 190 + border-color: #0e639c; 191 + } 192 + 193 + .editor-actions { 194 + padding: 16px; 195 + border-top: 1px solid #3e3e42; 196 + display: flex; 197 + gap: 8px; 198 + } 199 + 200 + .btn { 201 + padding: 8px 16px; 202 + border: none; 203 + border-radius: 4px; 204 + cursor: pointer; 205 + font-size: 13px; 206 + } 207 + 208 + .btn-primary { 209 + background: #0e639c; 210 + color: white; 211 + } 212 + 213 + .btn-primary:hover { 214 + background: #1177bb; 215 + } 216 + 217 + .btn-danger { 218 + background: #a1260d; 219 + color: white; 220 + } 221 + 222 + .btn-danger:hover { 223 + background: #c72e0d; 224 + } 225 + 226 + .btn-secondary { 227 + background: #3c3c3c; 228 + color: #d4d4d4; 229 + } 230 + 231 + .btn-secondary:hover { 232 + background: #505050; 233 + } 234 + 235 + /* Right Panel - Preview */ 236 + .preview-panel { 237 + width: 350px; 238 + background: #252526; 239 + border-left: 1px solid #3e3e42; 240 + display: flex; 241 + flex-direction: column; 242 + } 243 + 244 + .preview-header { 245 + padding: 16px; 246 + border-bottom: 1px solid #3e3e42; 247 + } 248 + 249 + .preview-header h2 { 250 + font-size: 14px; 251 + font-weight: 600; 252 + margin-bottom: 12px; 253 + } 254 + 255 + .test-url-group { 256 + display: flex; 257 + flex-direction: column; 258 + gap: 8px; 259 + } 260 + 261 + .test-url-input { 262 + padding: 8px; 263 + background: #3c3c3c; 264 + border: 1px solid #3e3e42; 265 + color: #d4d4d4; 266 + border-radius: 3px; 267 + font-size: 13px; 268 + } 269 + 270 + .test-btn { 271 + padding: 8px; 272 + background: #0e639c; 273 + color: white; 274 + border: none; 275 + border-radius: 4px; 276 + cursor: pointer; 277 + font-size: 13px; 278 + } 279 + 280 + .test-btn:hover { 281 + background: #1177bb; 282 + } 283 + 284 + .test-btn:disabled { 285 + background: #505050; 286 + cursor: not-allowed; 287 + } 288 + 289 + .preview-content { 290 + flex: 1; 291 + padding: 16px; 292 + overflow-y: auto; 293 + } 294 + 295 + .result-box { 296 + background: #2d2d30; 297 + border: 1px solid #3e3e42; 298 + border-radius: 4px; 299 + padding: 12px; 300 + margin-bottom: 12px; 301 + } 302 + 303 + .result-status { 304 + display: flex; 305 + align-items: center; 306 + gap: 8px; 307 + margin-bottom: 8px; 308 + font-size: 13px; 309 + } 310 + 311 + .result-status.success { 312 + color: #4ec9b0; 313 + } 314 + 315 + .result-status.error { 316 + color: #f48771; 317 + } 318 + 319 + .result-time { 320 + color: #858585; 321 + font-size: 12px; 322 + } 323 + 324 + .result-data { 325 + background: #1e1e1e; 326 + padding: 8px; 327 + border-radius: 3px; 328 + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; 329 + font-size: 12px; 330 + overflow-x: auto; 331 + white-space: pre-wrap; 332 + } 333 + 334 + .empty-state { 335 + color: #858585; 336 + text-align: center; 337 + padding: 40px 20px; 338 + font-size: 13px; 339 + } 340 + 341 + .console-output { 342 + margin-top: 12px; 343 + } 344 + 345 + .console-output h3 { 346 + font-size: 12px; 347 + color: #858585; 348 + margin-bottom: 8px; 349 + } 350 + 351 + .console-line { 352 + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; 353 + font-size: 11px; 354 + padding: 2px 0; 355 + } 356 + 357 + .console-line.log { 358 + color: #d4d4d4; 359 + } 360 + 361 + .console-line.error { 362 + color: #f48771; 363 + } 364 + 365 + .console-line.warn { 366 + color: #dcdcaa; 367 + } 368 + </style> 369 + </head> 370 + <body> 371 + <div class="container"> 372 + <!-- Left Panel: Scripts List --> 373 + <div class="scripts-list"> 374 + <div class="scripts-header"> 375 + <h1>Scripts</h1> 376 + <button class="new-script-btn" id="newScriptBtn">+ New Script</button> 377 + </div> 378 + <div class="scripts-items" id="scriptsList"> 379 + <div class="empty-state">No scripts yet. Click "+ New Script" to create one.</div> 380 + </div> 381 + </div> 382 + 383 + <!-- Center Panel: Editor --> 384 + <div class="editor-panel"> 385 + <div class="editor-header"> 386 + <div class="editor-form" id="editorForm"> 387 + <div class="form-group"> 388 + <label>Name</label> 389 + <input type="text" id="scriptName" placeholder="My Script"> 390 + </div> 391 + <div style="display: flex; gap: 12px;"> 392 + <div class="form-group" style="flex: 1;"> 393 + <label>Match Pattern</label> 394 + <input type="text" id="matchPattern" placeholder="https://example.com/*"> 395 + </div> 396 + <div class="form-group" style="width: 150px;"> 397 + <label>Run At</label> 398 + <select id="runAt"> 399 + <option value="document-start">Document Start</option> 400 + <option value="document-end" selected>Document End</option> 401 + <option value="document-idle">Document Idle</option> 402 + </select> 403 + </div> 404 + </div> 405 + <div class="form-group"> 406 + <label>Description</label> 407 + <input type="text" id="scriptDescription" placeholder="What does this script do?"> 408 + </div> 409 + </div> 410 + </div> 411 + <div class="editor-content"> 412 + <textarea class="code-editor" id="codeEditor" placeholder="// Your script here&#10;const h1 = document.querySelector('h1');&#10;return { title: h1?.textContent || 'No h1 found' };"></textarea> 413 + </div> 414 + <div class="editor-actions"> 415 + <button class="btn btn-primary" id="saveBtn">Save</button> 416 + <button class="btn btn-secondary" id="revertBtn">Revert</button> 417 + <button class="btn btn-danger" id="deleteBtn">Delete</button> 418 + </div> 419 + </div> 420 + 421 + <!-- Right Panel: Preview --> 422 + <div class="preview-panel"> 423 + <div class="preview-header"> 424 + <h2>Test & Preview</h2> 425 + <div class="test-url-group"> 426 + <input type="text" class="test-url-input" id="testUrl" placeholder="https://example.com"> 427 + <button class="test-btn" id="testBtn">Test Script</button> 428 + </div> 429 + </div> 430 + <div class="preview-content" id="previewContent"> 431 + <div class="empty-state"> 432 + Enter a test URL and click "Test Script" to see results. 433 + </div> 434 + </div> 435 + </div> 436 + </div> 437 + 438 + <script type="module" src="./manager.js"></script> 439 + </body> 440 + </html>
+426
extensions/scripts/manager.js
··· 1 + /** 2 + * Scripts Manager UI 3 + * 4 + * Three-panel layout: 5 + * - Left: Scripts list 6 + * - Center: Editor 7 + * - Right: Preview/Test 8 + */ 9 + 10 + import { scriptExecutor } from './script-executor.js'; 11 + 12 + const api = window.app; 13 + 14 + // UI Elements 15 + let scriptsList; 16 + let editorForm; 17 + let scriptName; 18 + let scriptDescription; 19 + let matchPattern; 20 + let runAt; 21 + let codeEditor; 22 + let testUrl; 23 + let previewContent; 24 + let saveBtn; 25 + let revertBtn; 26 + let deleteBtn; 27 + let testBtn; 28 + let newScriptBtn; 29 + 30 + // State 31 + let scripts = []; 32 + let currentScript = null; 33 + let originalScript = null; // For revert 34 + 35 + /** 36 + * Initialize UI 37 + */ 38 + async function init() { 39 + console.log('[scripts-manager] Initializing...'); 40 + 41 + // Get DOM elements 42 + scriptsList = document.getElementById('scriptsList'); 43 + editorForm = document.getElementById('editorForm'); 44 + scriptName = document.getElementById('scriptName'); 45 + scriptDescription = document.getElementById('scriptDescription'); 46 + matchPattern = document.getElementById('matchPattern'); 47 + runAt = document.getElementById('runAt'); 48 + codeEditor = document.getElementById('codeEditor'); 49 + testUrl = document.getElementById('testUrl'); 50 + previewContent = document.getElementById('previewContent'); 51 + saveBtn = document.getElementById('saveBtn'); 52 + revertBtn = document.getElementById('revertBtn'); 53 + deleteBtn = document.getElementById('deleteBtn'); 54 + testBtn = document.getElementById('testBtn'); 55 + newScriptBtn = document.getElementById('newScriptBtn'); 56 + 57 + // Attach event listeners 58 + newScriptBtn.addEventListener('click', handleNewScript); 59 + saveBtn.addEventListener('click', handleSave); 60 + revertBtn.addEventListener('click', handleRevert); 61 + deleteBtn.addEventListener('click', handleDelete); 62 + testBtn.addEventListener('click', handleTest); 63 + 64 + // Load scripts from backend 65 + await loadScripts(); 66 + 67 + // Subscribe to updates 68 + api.subscribe('scripts:created', () => loadScripts(), api.scopes.GLOBAL); 69 + api.subscribe('scripts:updated', () => loadScripts(), api.scopes.GLOBAL); 70 + api.subscribe('scripts:deleted', () => loadScripts(), api.scopes.GLOBAL); 71 + 72 + // Check URL params for scriptId 73 + const params = new URLSearchParams(window.location.search); 74 + const scriptId = params.get('scriptId'); 75 + if (scriptId) { 76 + const script = scripts.find(s => s.id === scriptId); 77 + if (script) { 78 + selectScript(script); 79 + } 80 + } 81 + 82 + console.log('[scripts-manager] Initialized with', scripts.length, 'scripts'); 83 + } 84 + 85 + /** 86 + * Load all scripts from backend 87 + */ 88 + async function loadScripts() { 89 + return new Promise((resolve) => { 90 + api.subscribe('scripts:get-all:response', (msg) => { 91 + if (msg.success) { 92 + scripts = msg.data || []; 93 + renderScriptsList(); 94 + resolve(); 95 + } 96 + }, api.scopes.GLOBAL); 97 + 98 + api.publish('scripts:get-all', {}, api.scopes.GLOBAL); 99 + }); 100 + } 101 + 102 + /** 103 + * Render scripts list in left panel 104 + */ 105 + function renderScriptsList() { 106 + if (scripts.length === 0) { 107 + scriptsList.innerHTML = '<div class="empty-state">No scripts yet. Click "+ New Script" to create one.</div>'; 108 + return; 109 + } 110 + 111 + scriptsList.innerHTML = scripts.map(script => { 112 + const status = script.lastExecutedAt 113 + ? (script.enabled ? 'success' : 'disabled') 114 + : 'never'; 115 + 116 + const lastRun = script.lastExecutedAt 117 + ? formatTime(script.lastExecutedAt) 118 + : 'Never run'; 119 + 120 + const isActive = currentScript && currentScript.id === script.id; 121 + 122 + return ` 123 + <div class="script-item ${isActive ? 'active' : ''}" data-script-id="${script.id}"> 124 + <div class="script-item-header"> 125 + <input type="checkbox" class="script-checkbox" ${script.enabled ? 'checked' : ''} data-script-id="${script.id}"> 126 + <span class="script-name">${escapeHtml(script.name)}</span> 127 + <span class="script-status ${status}"></span> 128 + </div> 129 + <div class="script-meta">${lastRun}</div> 130 + </div> 131 + `; 132 + }).join(''); 133 + 134 + // Attach click handlers 135 + scriptsList.querySelectorAll('.script-item').forEach(item => { 136 + item.addEventListener('click', (e) => { 137 + if (e.target.classList.contains('script-checkbox')) { 138 + return; // Handle checkbox separately 139 + } 140 + const scriptId = item.dataset.scriptId; 141 + const script = scripts.find(s => s.id === scriptId); 142 + if (script) { 143 + selectScript(script); 144 + } 145 + }); 146 + }); 147 + 148 + // Attach checkbox handlers 149 + scriptsList.querySelectorAll('.script-checkbox').forEach(checkbox => { 150 + checkbox.addEventListener('change', async (e) => { 151 + e.stopPropagation(); 152 + const scriptId = checkbox.dataset.scriptId; 153 + const enabled = checkbox.checked; 154 + 155 + await updateScriptField(scriptId, { enabled }); 156 + }); 157 + }); 158 + } 159 + 160 + /** 161 + * Select a script to edit 162 + */ 163 + function selectScript(script) { 164 + currentScript = script; 165 + originalScript = JSON.parse(JSON.stringify(script)); // Deep clone for revert 166 + 167 + // Populate form 168 + scriptName.value = script.name; 169 + scriptDescription.value = script.description || ''; 170 + matchPattern.value = script.matchPatterns[0] || ''; 171 + runAt.value = script.runAt; 172 + codeEditor.value = script.code; 173 + 174 + // Enable actions 175 + deleteBtn.disabled = false; 176 + 177 + // Re-render to show active state 178 + renderScriptsList(); 179 + } 180 + 181 + /** 182 + * Handle new script creation 183 + */ 184 + async function handleNewScript() { 185 + return new Promise((resolve) => { 186 + api.subscribe('scripts:create:response', async (msg) => { 187 + if (msg.success) { 188 + await loadScripts(); 189 + selectScript(msg.data); 190 + resolve(); 191 + } 192 + }, api.scopes.GLOBAL); 193 + 194 + api.publish('scripts:create', { 195 + name: 'New Script', 196 + code: '// Your script here\nconst h1 = document.querySelector(\'h1\');\nreturn { title: h1?.textContent || \'No h1 found\' };' 197 + }, api.scopes.GLOBAL); 198 + }); 199 + } 200 + 201 + /** 202 + * Handle save 203 + */ 204 + async function handleSave() { 205 + if (!currentScript) return; 206 + 207 + const updates = { 208 + name: scriptName.value, 209 + description: scriptDescription.value, 210 + matchPatterns: [matchPattern.value], 211 + runAt: runAt.value, 212 + code: codeEditor.value 213 + }; 214 + 215 + await updateScriptField(currentScript.id, updates); 216 + } 217 + 218 + /** 219 + * Handle revert 220 + */ 221 + function handleRevert() { 222 + if (!originalScript) return; 223 + selectScript(originalScript); 224 + } 225 + 226 + /** 227 + * Handle delete 228 + */ 229 + async function handleDelete() { 230 + if (!currentScript) return; 231 + 232 + if (!confirm(`Delete script "${currentScript.name}"?`)) { 233 + return; 234 + } 235 + 236 + return new Promise((resolve) => { 237 + api.subscribe('scripts:delete:response', async (msg) => { 238 + if (msg.success) { 239 + currentScript = null; 240 + originalScript = null; 241 + await loadScripts(); 242 + 243 + // Clear form 244 + scriptName.value = ''; 245 + scriptDescription.value = ''; 246 + matchPattern.value = ''; 247 + codeEditor.value = ''; 248 + deleteBtn.disabled = true; 249 + 250 + resolve(); 251 + } 252 + }, api.scopes.GLOBAL); 253 + 254 + api.publish('scripts:delete', { 255 + scriptId: currentScript.id 256 + }, api.scopes.GLOBAL); 257 + }); 258 + } 259 + 260 + /** 261 + * Handle test execution 262 + */ 263 + async function handleTest() { 264 + if (!currentScript) { 265 + showPreviewError('No script selected'); 266 + return; 267 + } 268 + 269 + const url = testUrl.value.trim(); 270 + if (!url) { 271 + showPreviewError('Please enter a test URL'); 272 + return; 273 + } 274 + 275 + testBtn.disabled = true; 276 + testBtn.textContent = 'Running...'; 277 + 278 + try { 279 + // Use current form values (not saved script) 280 + const testScript = { 281 + ...currentScript, 282 + name: scriptName.value, 283 + code: codeEditor.value, 284 + matchPatterns: [matchPattern.value], 285 + runAt: runAt.value 286 + }; 287 + 288 + const result = await scriptExecutor.executeScript(testScript, { 289 + url: url, 290 + pageDOM: document, 291 + pageWindow: window 292 + }); 293 + 294 + showPreviewResult(result); 295 + } catch (error) { 296 + showPreviewError(error.message); 297 + } finally { 298 + testBtn.disabled = false; 299 + testBtn.textContent = 'Test Script'; 300 + } 301 + } 302 + 303 + /** 304 + * Update script field 305 + */ 306 + async function updateScriptField(scriptId, updates) { 307 + return new Promise((resolve) => { 308 + api.subscribe('scripts:update:response', async (msg) => { 309 + if (msg.success) { 310 + await loadScripts(); 311 + if (currentScript && currentScript.id === scriptId) { 312 + const updatedScript = scripts.find(s => s.id === scriptId); 313 + if (updatedScript) { 314 + currentScript = updatedScript; 315 + originalScript = JSON.parse(JSON.stringify(updatedScript)); 316 + } 317 + } 318 + resolve(); 319 + } 320 + }, api.scopes.GLOBAL); 321 + 322 + api.publish('scripts:update', { 323 + scriptId, 324 + updates 325 + }, api.scopes.GLOBAL); 326 + }); 327 + } 328 + 329 + /** 330 + * Show preview result 331 + */ 332 + function showPreviewResult(result) { 333 + let html = ''; 334 + 335 + if (result.status === 'success') { 336 + html = ` 337 + <div class="result-box"> 338 + <div class="result-status success">✓ Success</div> 339 + <div class="result-time">Execution time: ${result.executionTime}ms</div> 340 + ${result.result !== undefined ? ` 341 + <div style="margin-top: 12px;"> 342 + <strong style="font-size: 12px; color: #858585;">Result:</strong> 343 + <div class="result-data">${JSON.stringify(result.result, null, 2)}</div> 344 + </div> 345 + ` : ''} 346 + ${result.output && result.output.length > 0 ? ` 347 + <div class="console-output"> 348 + <h3>Console Output:</h3> 349 + ${result.output.map(log => ` 350 + <div class="console-line ${log.level}">${escapeHtml(log.message)}</div> 351 + `).join('')} 352 + </div> 353 + ` : ''} 354 + </div> 355 + `; 356 + } else if (result.status === 'error') { 357 + html = ` 358 + <div class="result-box"> 359 + <div class="result-status error">✗ Error</div> 360 + <div class="result-data">${escapeHtml(result.error)}</div> 361 + ${result.stack ? ` 362 + <div style="margin-top: 8px; font-size: 11px; color: #858585;"> 363 + <strong>Stack:</strong> 364 + <pre style="margin-top: 4px;">${escapeHtml(result.stack)}</pre> 365 + </div> 366 + ` : ''} 367 + </div> 368 + `; 369 + } else if (result.status === 'skipped') { 370 + html = ` 371 + <div class="result-box"> 372 + <div class="result-status" style="color: #dcdcaa;">⊘ Skipped</div> 373 + <div style="font-size: 12px; color: #858585;">${escapeHtml(result.reason)}</div> 374 + </div> 375 + `; 376 + } 377 + 378 + previewContent.innerHTML = html; 379 + } 380 + 381 + /** 382 + * Show preview error 383 + */ 384 + function showPreviewError(message) { 385 + previewContent.innerHTML = ` 386 + <div class="result-box"> 387 + <div class="result-status error">✗ Error</div> 388 + <div class="result-data">${escapeHtml(message)}</div> 389 + </div> 390 + `; 391 + } 392 + 393 + /** 394 + * Format timestamp 395 + */ 396 + function formatTime(timestamp) { 397 + const now = Date.now(); 398 + const diff = now - timestamp; 399 + 400 + const seconds = Math.floor(diff / 1000); 401 + const minutes = Math.floor(seconds / 60); 402 + const hours = Math.floor(minutes / 60); 403 + const days = Math.floor(hours / 24); 404 + 405 + if (days > 0) return `${days}d ago`; 406 + if (hours > 0) return `${hours}h ago`; 407 + if (minutes > 0) return `${minutes}m ago`; 408 + if (seconds > 0) return `${seconds}s ago`; 409 + return 'Just now'; 410 + } 411 + 412 + /** 413 + * Escape HTML 414 + */ 415 + function escapeHtml(text) { 416 + const div = document.createElement('div'); 417 + div.textContent = text; 418 + return div.innerHTML; 419 + } 420 + 421 + // Initialize on load 422 + if (document.readyState === 'loading') { 423 + document.addEventListener('DOMContentLoaded', init); 424 + } else { 425 + init(); 426 + }
+10
extensions/scripts/manifest.json
··· 1 + { 2 + "id": "scripts", 3 + "shortname": "scripts", 4 + "name": "Scripts", 5 + "description": "Userscripts and content scripts system for web automation", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "builtin": true, 9 + "settingsSchema": "./settings-schema.json" 10 + }
+175
extensions/scripts/script-executor.js
··· 1 + /** 2 + * Script Executor - Core execution engine for userscripts 3 + * 4 + * Handles: 5 + * - Pattern matching (glob patterns) 6 + * - Timeout protection 7 + * - Console capture 8 + * - Dual execution modes (peek:// and http://) 9 + */ 10 + 11 + export class ScriptExecutor { 12 + constructor() { 13 + this.defaultTimeout = 5000; // 5 seconds 14 + } 15 + 16 + /** 17 + * Execute a script against a page URL or DOM 18 + * @param {object} script - Script object with code, matchPatterns, etc. 19 + * @param {object} executionContext - Context object with url, pageDOM, pageWindow, timeout 20 + * @returns {Promise<object>} Execution result 21 + */ 22 + async executeScript(script, executionContext) { 23 + const { 24 + url, 25 + pageDOM = document, 26 + pageWindow = window, 27 + timeout = this.defaultTimeout 28 + } = executionContext; 29 + 30 + // Validate script matches URL 31 + if (!this.matchesUrl(script, url)) { 32 + return { 33 + status: 'skipped', 34 + reason: 'URL does not match script patterns' 35 + }; 36 + } 37 + 38 + try { 39 + const result = await this.runScriptInContext( 40 + script.code, 41 + pageDOM, 42 + pageWindow, 43 + timeout 44 + ); 45 + 46 + return { 47 + status: 'success', 48 + result: result.result, 49 + executionTime: result.time, 50 + output: result.output 51 + }; 52 + } catch (error) { 53 + return { 54 + status: 'error', 55 + error: error.message, 56 + stack: error.stack 57 + }; 58 + } 59 + } 60 + 61 + /** 62 + * Check if script's match patterns apply to URL 63 + * @param {object} script - Script with matchPatterns and excludePatterns 64 + * @param {string} url - URL to test 65 + * @returns {boolean} 66 + */ 67 + matchesUrl(script, url) { 68 + const matches = script.matchPatterns.some(pattern => 69 + this.matchPattern(pattern, url) 70 + ); 71 + 72 + const excluded = script.excludePatterns && script.excludePatterns.length > 0 73 + ? script.excludePatterns.some(pattern => this.matchPattern(pattern, url)) 74 + : false; 75 + 76 + return matches && !excluded; 77 + } 78 + 79 + /** 80 + * Match a single pattern against URL 81 + * Supports glob patterns: https://example.com/*, *://example.com/*, etc. 82 + * @param {string} pattern - Glob pattern 83 + * @param {string} url - URL to test 84 + * @returns {boolean} 85 + */ 86 + matchPattern(pattern, url) { 87 + // Special case: match all 88 + if (pattern === '*' || pattern === '<all_urls>') { 89 + return true; 90 + } 91 + 92 + // Convert glob pattern to regex 93 + const regex = new RegExp( 94 + '^' + pattern 95 + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars 96 + .replace(/\*/g, '.*') // * -> .* 97 + .replace(/\?/g, '.') // ? -> . 98 + + '$' 99 + ); 100 + 101 + return regex.test(url); 102 + } 103 + 104 + /** 105 + * Execute script with timeout, capturing console output 106 + * @param {string} code - JavaScript code to execute 107 + * @param {Document} document - Document object 108 + * @param {Window} window - Window object 109 + * @param {number} timeout - Timeout in milliseconds 110 + * @returns {Promise<object>} Result with time, output, and result 111 + */ 112 + async runScriptInContext(code, document, window, timeout) { 113 + const startTime = performance.now(); 114 + const logs = []; 115 + 116 + // Capture console output 117 + const originalLog = console.log; 118 + const originalError = console.error; 119 + const originalWarn = console.warn; 120 + 121 + console.log = (...args) => { 122 + logs.push({ level: 'log', message: this.formatArgs(args) }); 123 + originalLog(...args); 124 + }; 125 + console.error = (...args) => { 126 + logs.push({ level: 'error', message: this.formatArgs(args) }); 127 + originalError(...args); 128 + }; 129 + console.warn = (...args) => { 130 + logs.push({ level: 'warn', message: this.formatArgs(args) }); 131 + originalWarn(...args); 132 + }; 133 + 134 + try { 135 + // Wrap in async function to support await 136 + const wrappedCode = ` 137 + (async () => { 138 + ${code} 139 + })() 140 + `; 141 + 142 + const fn = new Function('document', 'window', `return ${wrappedCode}`); 143 + const result = await Promise.race([ 144 + fn(document, window), 145 + new Promise((_, reject) => 146 + setTimeout(() => reject(new Error('Script timeout')), timeout) 147 + ) 148 + ]); 149 + 150 + return { 151 + time: Math.round(performance.now() - startTime), 152 + output: logs, 153 + result 154 + }; 155 + } finally { 156 + console.log = originalLog; 157 + console.error = originalError; 158 + console.warn = originalWarn; 159 + } 160 + } 161 + 162 + /** 163 + * Format console arguments for storage 164 + * @param {Array} args - Console arguments 165 + * @returns {string} 166 + */ 167 + formatArgs(args) { 168 + return args.map(a => 169 + typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a) 170 + ).join(' '); 171 + } 172 + } 173 + 174 + // Export singleton instance 175 + export const scriptExecutor = new ScriptExecutor();
+35
extensions/scripts/settings-schema.json
··· 1 + { 2 + "type": "object", 3 + "properties": { 4 + "scripts": { 5 + "type": "array", 6 + "description": "List of user scripts", 7 + "items": { 8 + "type": "object", 9 + "properties": { 10 + "id": { "type": "string" }, 11 + "name": { "type": "string" }, 12 + "description": { "type": "string" }, 13 + "code": { "type": "string" }, 14 + "matchPatterns": { 15 + "type": "array", 16 + "items": { "type": "string" } 17 + }, 18 + "excludePatterns": { 19 + "type": "array", 20 + "items": { "type": "string" } 21 + }, 22 + "runAt": { 23 + "type": "string", 24 + "enum": ["document-start", "document-end", "document-idle"] 25 + }, 26 + "enabled": { "type": "boolean" }, 27 + "createdAt": { "type": "number" }, 28 + "updatedAt": { "type": "number" }, 29 + "lastExecutedAt": { "type": "number" } 30 + } 31 + }, 32 + "default": [] 33 + } 34 + } 35 + }
+1991
notes/migration-ui-componentry.md
··· 1 + # UI Component Migration Plan 2 + 3 + ## Executive Summary 4 + 5 + This document provides a detailed plan for migrating the Groups, Tags, and Windows extensions from manual innerHTML rendering to using the existing Lit-based component library in `app/components/`. This migration will unify UI patterns, reduce code duplication, improve maintainability, and provide consistent styling and behavior across all extensions. 6 + 7 + --- 8 + 9 + ## Current State Analysis 10 + 11 + ### Available Lit Components 12 + 13 + The `app/components/` directory provides a comprehensive Lit-based component library: 14 + 15 + **Layout Components:** 16 + - `peek-grid` - Responsive CSS Grid with auto-fit columns 17 + - `peek-card` - Flexible card container with header/body/footer slots 18 + - `peek-list` / `peek-list-item` - Keyboard-navigable lists with selection 19 + 20 + **Form Components:** 21 + - `peek-input` - Input field with autocomplete suggestions 22 + - `peek-button` - Themeable button with variants 23 + - `peek-button-group` - Segmented controls and tag sets 24 + 25 + **Interactive Components:** 26 + - `peek-dialog` - Modal/non-modal dialogs 27 + - `peek-drawer` - Slide-out panels 28 + - `peek-popover` - Tooltips and floating content 29 + - `peek-tabs` - Tab navigation 30 + 31 + **Design System:** 32 + - Shared theme tokens from `peek://theme/variables.css` 33 + - Built-in accessibility (ARIA patterns, keyboard navigation) 34 + - Consistent styling via CSS custom properties 35 + - Event system with composed events 36 + 37 + ### Extension Current Implementations 38 + 39 + #### 1. Groups Extension (`extensions/groups/`) 40 + 41 + **Current Rendering Approach:** 42 + - Manual DOM creation using `document.createElement()` 43 + - Cards rendered in `.cards` container with CSS Grid 44 + - Two card types: group cards and address cards 45 + - Hand-rolled keyboard navigation (vim-style hjkl) 46 + - Manual selection state management 47 + 48 + **Current HTML Structure:** 49 + ```html 50 + <div class="search-container"> 51 + <input type="text" class="search-input" placeholder="Search groups..."> 52 + </div> 53 + <main class="cards"></main> 54 + ``` 55 + 56 + **Current Card Creation (Manual):** 57 + ```javascript 58 + const createGroupCard = (tag) => { 59 + const card = document.createElement('div'); 60 + card.className = 'card group-card'; 61 + 62 + const colorDot = document.createElement('div'); 63 + colorDot.className = 'color-dot'; 64 + colorDot.style.backgroundColor = tag.color || '#999'; 65 + 66 + const content = document.createElement('div'); 67 + content.className = 'card-content'; 68 + 69 + const title = document.createElement('h2'); 70 + title.className = 'card-title'; 71 + title.textContent = tag.name; 72 + 73 + const meta = document.createElement('div'); 74 + meta.className = 'card-meta'; 75 + meta.textContent = `${count} pages`; 76 + 77 + content.appendChild(title); 78 + content.appendChild(meta); 79 + card.appendChild(colorDot); 80 + card.appendChild(content); 81 + 82 + card.addEventListener('click', () => showAddresses(tag)); 83 + return card; 84 + }; 85 + ``` 86 + 87 + **Current CSS:** 88 + - Custom grid layout: `grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))` 89 + - Custom card styling with hover effects 90 + - Theme tokens from `peek://theme/variables.css` 91 + 92 + **Key Features:** 93 + - Two-view navigation (groups list → addresses list) 94 + - Vim-style keyboard navigation (hjkl) 95 + - Search filtering 96 + - Grid-based card layout 97 + - Selection highlighting 98 + - Escape key navigation back 99 + 100 + #### 2. Tags Extension (`extensions/tags/`) 101 + 102 + **Current Rendering Approach:** 103 + - Manual DOM creation with innerHTML for cards 104 + - Sidebar with tag chips 105 + - Modal overlay for tag editing 106 + - Similar manual keyboard navigation 107 + 108 + **Current HTML Structure:** 109 + ```html 110 + <header class="header"> 111 + <!-- Filter buttons, add item button --> 112 + </header> 113 + <div class="search-container"> 114 + <input type="text" class="search-input" placeholder="Search items and tags..."> 115 + </div> 116 + <div class="content-wrapper"> 117 + <aside class="tag-sidebar"> 118 + <div class="tag-list"></div> 119 + </aside> 120 + <main class="items-container"> 121 + <div class="cards"></div> 122 + </main> 123 + </div> 124 + <div class="modal-overlay" id="editModal"> 125 + <!-- Edit modal structure --> 126 + </div> 127 + ``` 128 + 129 + **Current Card Creation (innerHTML):** 130 + ```javascript 131 + const createItemCard = (item) => { 132 + const card = document.createElement('div'); 133 + card.className = 'card'; 134 + card.innerHTML = ` 135 + <div class="card-header"> 136 + <img class="card-favicon" src="${faviconUrl}"> 137 + <div class="card-content"> 138 + <div class="card-title">${escapeHtml(title)}</div> 139 + <div class="card-url">${escapeHtml(subtitle)}</div> 140 + </div> 141 + </div> 142 + <div class="card-tags"> 143 + ${tags.map(tag => `<span class="card-tag">${escapeHtml(tag.name)}</span>`).join('')} 144 + </div> 145 + `; 146 + card.addEventListener('click', (e) => { 147 + if (e.target.classList.contains('card-tag')) { 148 + // Handle tag filter 149 + } else { 150 + openEditModal(item); 151 + } 152 + }); 153 + return card; 154 + }; 155 + ``` 156 + 157 + **Key Features:** 158 + - Sidebar with tag filtering 159 + - Multiple item types (pages, texts, tagsets, images) 160 + - Type filters with icons 161 + - Tag editing modal 162 + - Multi-tag filtering (AND logic) 163 + - Grid-based card layout 164 + 165 + #### 3. Windows Extension (`extensions/windows/`) 166 + 167 + **Current Rendering Approach:** 168 + - Simplest implementation - just cards in grid 169 + - Manual DOM creation for window cards 170 + - Similar keyboard navigation pattern 171 + 172 + **Current HTML Structure:** 173 + ```html 174 + <div class="search-container"> 175 + <input type="text" class="search-input" placeholder="Search windows..."> 176 + </div> 177 + <main class="cards"></main> 178 + ``` 179 + 180 + **Current Card Creation:** 181 + ```javascript 182 + const createWindowCard = (win) => { 183 + const card = document.createElement('div'); 184 + card.className = 'card'; 185 + 186 + if (faviconUrl) { 187 + const favicon = document.createElement('img'); 188 + favicon.className = 'card-favicon'; 189 + favicon.src = faviconUrl; 190 + card.appendChild(favicon); 191 + } 192 + 193 + const content = document.createElement('div'); 194 + content.className = 'card-content'; 195 + 196 + const title = document.createElement('div'); 197 + title.className = 'card-title'; 198 + title.textContent = win.title || 'Untitled'; 199 + 200 + const url = document.createElement('div'); 201 + url.className = 'card-url'; 202 + url.textContent = win.url || ''; 203 + 204 + content.appendChild(title); 205 + content.appendChild(url); 206 + card.appendChild(content); 207 + 208 + card.addEventListener('click', async () => { 209 + await api.window.focus(win.id); 210 + closeWindowsView(); 211 + }); 212 + 213 + return card; 214 + }; 215 + ``` 216 + 217 + **Key Features:** 218 + - Full-screen overlay mode 219 + - Window list with favicons 220 + - Click to focus window 221 + - Search filtering 222 + 223 + ### Common Patterns Across Extensions 224 + 225 + All three extensions share: 226 + 1. **Grid-based card layouts** - Auto-fill grids with responsive columns 227 + 2. **Card components** - Title, subtitle/URL, metadata, optional favicon 228 + 3. **Search filtering** - Input field with filtering logic 229 + 4. **Keyboard navigation** - Vim-style hjkl + arrow keys, grid-aware navigation 230 + 5. **Selection state** - Visual highlighting of selected card 231 + 6. **Empty states** - Messages when no results 232 + 7. **Theme tokens** - Using `peek://theme/variables.css` 233 + 234 + ### Current Rendering Logic Flow 235 + 236 + **Groups:** 237 + ``` 238 + loadTags() → renderGroups() → createGroupCard() × N → appendChild to .cards 239 + showAddresses() → renderAddresses() → createAddressCard() × N → appendChild to .cards 240 + ``` 241 + 242 + **Tags:** 243 + ``` 244 + loadData() → render() → renderCards() → createItemCard() × N → appendChild to .cards 245 + 246 + renderTagSidebar() → tag chips 247 + 248 + openEditModal() → modal with tag editing 249 + ``` 250 + 251 + **Windows:** 252 + ``` 253 + loadWindows() → renderWindows() → createWindowCard() × N → appendChild to .cards 254 + ``` 255 + 256 + --- 257 + 258 + ## Component Mapping 259 + 260 + ### How peek-card Works 261 + 262 + `peek-card` is a Lit web component that provides: 263 + - **Slots:** `header`, default (body), `footer`, `media` 264 + - **Properties:** `interactive`, `selected`, `elevated`, `bordered`, `glass` 265 + - **Events:** `card-click` when interactive 266 + - **Styling:** CSS custom properties for theming 267 + - **Accessibility:** Proper ARIA roles when interactive 268 + 269 + **Example Usage:** 270 + ```html 271 + <peek-card interactive elevated selected> 272 + <span slot="header">Card Title</span> 273 + <p>Body content</p> 274 + <span slot="footer">Footer text</span> 275 + </peek-card> 276 + ``` 277 + 278 + **Shadow DOM Structure:** 279 + ```html 280 + <div class="card" role="button" tabindex="0"> 281 + <div class="media"><slot name="media"></slot></div> 282 + <div class="header"><slot name="header"></slot></div> 283 + <div class="body"><slot></slot></div> 284 + <div class="footer"><slot name="footer"></slot></div> 285 + </div> 286 + ``` 287 + 288 + ### How peek-grid Works 289 + 290 + `peek-grid` provides responsive CSS Grid: 291 + - **Properties:** `min-item-width` (default 250px), `gap` (default 16px), `columns`, `align`, `dense`, `overlay` 292 + - **Auto-fit behavior:** `repeat(auto-fit, minmax(min(250px, 100%), 1fr))` 293 + - **Overlay mode:** Adds padding and max-width for full-screen overlays 294 + 295 + **Example Usage:** 296 + ```html 297 + <peek-grid min-item-width="200" gap="12"> 298 + <peek-card>Card 1</peek-card> 299 + <peek-card>Card 2</peek-card> 300 + </peek-grid> 301 + ``` 302 + 303 + ### How peek-input Works 304 + 305 + `peek-input` provides: 306 + - **Properties:** `value`, `placeholder`, `type`, `suggestions`, `autofocus` 307 + - **Autocomplete:** Dropdown with keyboard navigation 308 + - **Events:** `input`, `change`, `suggestion-select` 309 + - **Slots:** `prefix`, `suffix` for icons 310 + 311 + **Example Usage:** 312 + ```html 313 + <peek-input 314 + placeholder="Search..." 315 + .suggestions=${['tag1', 'tag2']} 316 + @input=${handleInput} 317 + ></peek-input> 318 + ``` 319 + 320 + ### Mapping Current Patterns to Components 321 + 322 + | Current Pattern | Component Solution | Benefits | 323 + |----------------|-------------------|----------| 324 + | Manual card creation | `<peek-card>` | Consistent styling, built-in interactivity, slots for flexibility | 325 + | CSS Grid layout | `<peek-grid>` | Responsive auto-fit, configurable gap/columns, overlay mode | 326 + | Search input | `<peek-input>` | Built-in suggestions, consistent styling, keyboard navigation | 327 + | Tag chips | `<peek-button-group>` | Selection state, keyboard navigation, variant styles | 328 + | Edit modal | `<peek-dialog>` | Native `<dialog>`, backdrop, Escape handling, slots | 329 + | Sidebar tags | `<peek-list>` | Keyboard navigation, selection state, accessibility | 330 + | Filter buttons | `<peek-button-group>` | Single/multiple selection modes, active state styling | 331 + 332 + --- 333 + 334 + ## Migration Steps 335 + 336 + ### Phase 1: Groups Extension 337 + 338 + **Goal:** Replace manual card creation with peek-card + peek-grid 339 + 340 + #### Step 1.1: Update HTML 341 + 342 + **Before:** 343 + ```html 344 + <div class="search-container"> 345 + <input type="text" class="search-input" placeholder="Search groups..."> 346 + </div> 347 + <main class="cards"></main> 348 + ``` 349 + 350 + **After:** 351 + ```html 352 + <script type="module"> 353 + import 'peek://app/components/peek-card.js'; 354 + import 'peek://app/components/peek-grid.js'; 355 + import 'peek://app/components/peek-input.js'; 356 + </script> 357 + 358 + <div class="search-container"> 359 + <peek-input 360 + class="search-input" 361 + placeholder="Search groups..." 362 + type="search" 363 + ></peek-input> 364 + </div> 365 + 366 + <peek-grid class="cards" min-item-width="200" gap="12"></peek-grid> 367 + ``` 368 + 369 + #### Step 1.2: Update JavaScript Card Creation 370 + 371 + **Before:** 372 + ```javascript 373 + const createGroupCard = (tag) => { 374 + const card = document.createElement('div'); 375 + card.className = 'card group-card'; 376 + 377 + const colorDot = document.createElement('div'); 378 + colorDot.className = 'color-dot'; 379 + colorDot.style.backgroundColor = tag.color || '#999'; 380 + 381 + const content = document.createElement('div'); 382 + content.className = 'card-content'; 383 + 384 + const title = document.createElement('h2'); 385 + title.className = 'card-title'; 386 + title.textContent = tag.name; 387 + 388 + const meta = document.createElement('div'); 389 + meta.className = 'card-meta'; 390 + meta.textContent = `${count} pages`; 391 + 392 + content.appendChild(title); 393 + content.appendChild(meta); 394 + card.appendChild(colorDot); 395 + card.appendChild(content); 396 + 397 + card.addEventListener('click', () => showAddresses(tag)); 398 + return card; 399 + }; 400 + ``` 401 + 402 + **After:** 403 + ```javascript 404 + const createGroupCard = (tag) => { 405 + const card = document.createElement('peek-card'); 406 + card.interactive = true; 407 + card.elevated = true; 408 + card.dataset.tagId = tag.id; 409 + 410 + // Create header with color dot and title 411 + const header = document.createElement('div'); 412 + header.slot = 'header'; 413 + header.style.display = 'flex'; 414 + header.style.alignItems = 'center'; 415 + header.style.gap = '12px'; 416 + 417 + const colorDot = document.createElement('div'); 418 + colorDot.style.width = '12px'; 419 + colorDot.style.height = '12px'; 420 + colorDot.style.borderRadius = '50%'; 421 + colorDot.style.backgroundColor = tag.color || '#999'; 422 + 423 + const title = document.createElement('span'); 424 + title.textContent = tag.name; 425 + 426 + header.appendChild(colorDot); 427 + header.appendChild(title); 428 + card.appendChild(header); 429 + 430 + // Create footer with count 431 + const footer = document.createElement('span'); 432 + footer.slot = 'footer'; 433 + const count = tag.isSpecial ? (tag.frequency || 0) : (tag.addressCount || 0); 434 + footer.textContent = `${count} ${count === 1 ? 'page' : 'pages'}`; 435 + card.appendChild(footer); 436 + 437 + // Add click handler 438 + card.addEventListener('card-click', () => showAddresses(tag)); 439 + 440 + return card; 441 + }; 442 + ``` 443 + 444 + **Alternative (more idiomatic):** 445 + ```javascript 446 + const createGroupCard = (tag) => { 447 + const card = document.createElement('peek-card'); 448 + card.interactive = true; 449 + card.elevated = true; 450 + 451 + const count = tag.isSpecial ? (tag.frequency || 0) : (tag.addressCount || 0); 452 + 453 + card.innerHTML = ` 454 + <div slot="header" style="display: flex; align-items: center; gap: 12px;"> 455 + <div style="width: 12px; height: 12px; border-radius: 50%; background: ${tag.color || '#999'};"></div> 456 + <span>${tag.name}</span> 457 + </div> 458 + <span slot="footer">${count} ${count === 1 ? 'page' : 'pages'}</span> 459 + `; 460 + 461 + card.addEventListener('card-click', () => showAddresses(tag)); 462 + 463 + return card; 464 + }; 465 + ``` 466 + 467 + #### Step 1.3: Update Address Card Creation 468 + 469 + **Before:** 470 + ```javascript 471 + const createAddressCard = (address) => { 472 + const card = document.createElement('div'); 473 + card.className = 'card address-card'; 474 + 475 + const favicon = document.createElement('img'); 476 + favicon.className = 'card-favicon'; 477 + favicon.src = address.favicon || 'data:image/svg+xml,...'; 478 + 479 + const content = document.createElement('div'); 480 + content.className = 'card-content'; 481 + 482 + const title = document.createElement('h2'); 483 + title.className = 'card-title'; 484 + title.textContent = displayTitle; 485 + 486 + const url = document.createElement('div'); 487 + url.className = 'card-url'; 488 + url.textContent = addressUrl; 489 + 490 + const meta = document.createElement('div'); 491 + meta.className = 'card-meta'; 492 + meta.textContent = `${address.visitCount || 0} visits · Last: ${lastVisit}`; 493 + 494 + content.appendChild(title); 495 + content.appendChild(url); 496 + content.appendChild(meta); 497 + card.appendChild(favicon); 498 + card.appendChild(content); 499 + 500 + card.addEventListener('click', async () => { 501 + await api.window.open(addressUrl, { width: 800, height: 600 }); 502 + }); 503 + 504 + return card; 505 + }; 506 + ``` 507 + 508 + **After:** 509 + ```javascript 510 + const createAddressCard = (address) => { 511 + const card = document.createElement('peek-card'); 512 + card.interactive = true; 513 + card.elevated = true; 514 + 515 + const addressUrl = address.uri || address.content; 516 + const displayTitle = address.title || addressUrl; 517 + const lastVisit = address.lastVisitAt 518 + ? new Date(address.lastVisitAt).toLocaleDateString() 519 + : 'Never'; 520 + 521 + // Add favicon to media slot 522 + const favicon = document.createElement('img'); 523 + favicon.slot = 'media'; 524 + favicon.src = address.favicon || 'data:image/svg+xml,...'; 525 + favicon.alt = ''; 526 + favicon.style.width = '48px'; 527 + favicon.style.height = '48px'; 528 + favicon.style.objectFit = 'contain'; 529 + card.appendChild(favicon); 530 + 531 + // Add content via innerHTML 532 + card.innerHTML += ` 533 + <div slot="header">${escapeHtml(displayTitle)}</div> 534 + <div style="font-size: 12px; color: var(--base04);">${escapeHtml(addressUrl)}</div> 535 + <span slot="footer">${address.visitCount || 0} visits · Last: ${lastVisit}</span> 536 + `; 537 + 538 + card.addEventListener('card-click', async () => { 539 + await api.window.open(addressUrl, { width: 800, height: 600 }); 540 + }); 541 + 542 + return card; 543 + }; 544 + ``` 545 + 546 + #### Step 1.4: Update Search Input 547 + 548 + **Before:** 549 + ```javascript 550 + const searchInput = document.querySelector('.search-input'); 551 + searchInput.addEventListener('input', (e) => { 552 + state.searchQuery = e.target.value; 553 + renderCurrentView(); 554 + }); 555 + ``` 556 + 557 + **After:** 558 + ```javascript 559 + const searchInput = document.querySelector('peek-input'); 560 + searchInput.addEventListener('input', (e) => { 561 + state.searchQuery = searchInput.value; 562 + renderCurrentView(); 563 + }); 564 + ``` 565 + 566 + #### Step 1.5: Update Selection State Management 567 + 568 + **Before:** 569 + ```javascript 570 + const updateSelection = () => { 571 + const cards = getCards(); 572 + cards.forEach((card, i) => { 573 + card.classList.toggle('selected', i === state.selectedIndex); 574 + }); 575 + 576 + const selected = cards[state.selectedIndex]; 577 + if (selected) { 578 + selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 579 + } 580 + }; 581 + ``` 582 + 583 + **After:** 584 + ```javascript 585 + const updateSelection = () => { 586 + const cards = getCards(); 587 + cards.forEach((card, i) => { 588 + // peek-card has built-in selected property 589 + card.selected = i === state.selectedIndex; 590 + }); 591 + 592 + const selected = cards[state.selectedIndex]; 593 + if (selected) { 594 + selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 595 + } 596 + }; 597 + ``` 598 + 599 + #### Step 1.6: Update CSS 600 + 601 + **Remove:** 602 + - `.cards` grid layout (now handled by peek-grid) 603 + - `.card` styles (now handled by peek-card) 604 + - `.card:hover` and `.card.selected` (built-in) 605 + 606 + **Keep:** 607 + - Body/html base styles 608 + - Any custom styling for color dots, favicons in slots 609 + 610 + **Add CSS custom properties for customization:** 611 + ```css 612 + peek-grid { 613 + --peek-grid-min-item-width: 200px; 614 + --peek-grid-gap: 12px; 615 + } 616 + 617 + peek-card { 618 + --peek-card-bg: var(--base01); 619 + --peek-card-border: transparent; 620 + --peek-card-radius: 8px; 621 + --peek-card-padding: 16px; 622 + } 623 + 624 + peek-card[selected] { 625 + --peek-card-border: var(--base0D); 626 + } 627 + 628 + peek-input { 629 + --peek-input-bg: var(--base01); 630 + --peek-input-border: var(--base02); 631 + } 632 + ``` 633 + 634 + ### Phase 2: Tags Extension 635 + 636 + **Goal:** Replace cards + modal + sidebar with component equivalents 637 + 638 + #### Step 2.1: Update HTML 639 + 640 + **Before:** 641 + ```html 642 + <div class="search-container"> 643 + <input type="text" class="search-input" placeholder="Search items and tags..."> 644 + </div> 645 + <div class="content-wrapper"> 646 + <aside class="tag-sidebar"> 647 + <div class="tag-list"></div> 648 + </aside> 649 + <main class="items-container"> 650 + <div class="cards"></div> 651 + </main> 652 + </div> 653 + <div class="modal-overlay" id="editModal"> 654 + <!-- Modal structure --> 655 + </div> 656 + ``` 657 + 658 + **After:** 659 + ```html 660 + <script type="module"> 661 + import 'peek://app/components/peek-card.js'; 662 + import 'peek://app/components/peek-grid.js'; 663 + import 'peek://app/components/peek-input.js'; 664 + import 'peek://app/components/peek-dialog.js'; 665 + import 'peek://app/components/peek-list.js'; 666 + import 'peek://app/components/peek-button.js'; 667 + import 'peek://app/components/peek-button-group.js'; 668 + </script> 669 + 670 + <header class="header"> 671 + <!-- Keep existing header with filter buttons --> 672 + </header> 673 + 674 + <div class="search-container"> 675 + <peek-input 676 + class="search-input" 677 + placeholder="Search items and tags..." 678 + type="search" 679 + ></peek-input> 680 + </div> 681 + 682 + <div class="content-wrapper"> 683 + <aside class="tag-sidebar"> 684 + <h2 class="sidebar-title">Tags</h2> 685 + <peek-list class="tag-list" selection="multiple"></peek-list> 686 + </aside> 687 + <main class="items-container"> 688 + <peek-grid class="cards" min-item-width="200" gap="12"></peek-grid> 689 + </main> 690 + </div> 691 + 692 + <peek-dialog id="editModal" size="md" close-on-backdrop> 693 + <span slot="header">Edit Tags</span> 694 + <div class="modal-body"> 695 + <!-- Modal content --> 696 + </div> 697 + <div slot="footer"> 698 + <peek-button variant="ghost" onclick="editModal.close()">Close</peek-button> 699 + </div> 700 + </peek-dialog> 701 + ``` 702 + 703 + #### Step 2.2: Update Tag Sidebar Rendering 704 + 705 + **Before:** 706 + ```javascript 707 + const renderTagSidebar = () => { 708 + const tags = getFilteredTags(); 709 + tagList.innerHTML = ''; 710 + 711 + tags.forEach(tag => { 712 + const chip = document.createElement('div'); 713 + chip.className = 'tag-chip'; 714 + if (state.activeTags.some(t => t.id === tag.id)) { 715 + chip.classList.add('selected'); 716 + } 717 + chip.innerHTML = ` 718 + <span class="tag-name">${escapeHtml(tag.name)}</span> 719 + <span class="tag-count">${count}</span> 720 + `; 721 + chip.addEventListener('click', () => { 722 + // Toggle tag in activeTags 723 + }); 724 + tagList.appendChild(chip); 725 + }); 726 + }; 727 + ``` 728 + 729 + **After:** 730 + ```javascript 731 + const renderTagSidebar = () => { 732 + const tags = getFilteredTags(); 733 + const tagList = document.querySelector('.tag-list'); 734 + 735 + // Clear and rebuild 736 + tagList.innerHTML = ''; 737 + 738 + tags.forEach(tag => { 739 + let count = 0; 740 + state.itemTags.forEach(itemTags => { 741 + if (itemTags.some(t => t.id === tag.id)) count++; 742 + }); 743 + 744 + const item = document.createElement('peek-list-item'); 745 + item.value = tag.id; 746 + 747 + // Tag name in main slot 748 + const name = document.createElement('span'); 749 + name.textContent = tag.name; 750 + item.appendChild(name); 751 + 752 + // Count in suffix slot 753 + const badge = document.createElement('span'); 754 + badge.slot = 'suffix'; 755 + badge.textContent = count; 756 + badge.style.fontSize = '12px'; 757 + badge.style.color = 'var(--base03)'; 758 + item.appendChild(badge); 759 + 760 + tagList.appendChild(item); 761 + }); 762 + 763 + // Update selection 764 + const selectedIndices = []; 765 + tags.forEach((tag, i) => { 766 + if (state.activeTags.some(t => t.id === tag.id)) { 767 + selectedIndices.push(i); 768 + } 769 + }); 770 + tagList.selectedIndices = selectedIndices; 771 + 772 + // Handle selection changes 773 + tagList.addEventListener('selection-change', (e) => { 774 + // Update activeTags based on e.detail.selectedIndices 775 + state.activeTags = e.detail.selectedIndices.map(i => tags[i]); 776 + render(); 777 + }); 778 + }; 779 + ``` 780 + 781 + #### Step 2.3: Update Item Card Creation 782 + 783 + **Before:** 784 + ```javascript 785 + const createItemCard = (item) => { 786 + const card = document.createElement('div'); 787 + card.className = 'card'; 788 + card.innerHTML = ` 789 + <div class="card-header"> 790 + <img class="card-favicon" src="${faviconUrl}"> 791 + <div class="card-content"> 792 + <div class="card-title">${escapeHtml(title)}</div> 793 + <div class="card-url">${escapeHtml(subtitle)}</div> 794 + </div> 795 + </div> 796 + <div class="card-tags"> 797 + ${tags.map(tag => `<span class="card-tag">${tag.name}</span>`).join('')} 798 + </div> 799 + `; 800 + card.addEventListener('click', (e) => { 801 + if (e.target.classList.contains('card-tag')) { 802 + // Handle tag click 803 + } else { 804 + openEditModal(item); 805 + } 806 + }); 807 + return card; 808 + }; 809 + ``` 810 + 811 + **After:** 812 + ```javascript 813 + const createItemCard = (item) => { 814 + const card = document.createElement('peek-card'); 815 + card.interactive = true; 816 + card.elevated = true; 817 + 818 + const tags = state.itemTags.get(item.id) || []; 819 + const { title, subtitle, faviconUrl } = getItemDisplayInfo(item); 820 + 821 + // Header with favicon + title 822 + const header = document.createElement('div'); 823 + header.slot = 'header'; 824 + header.style.display = 'flex'; 825 + header.style.alignItems = 'center'; 826 + header.style.gap = '12px'; 827 + 828 + const favicon = document.createElement('img'); 829 + favicon.src = faviconUrl; 830 + favicon.alt = ''; 831 + favicon.style.width = '32px'; 832 + favicon.style.height = '32px'; 833 + favicon.style.borderRadius = '4px'; 834 + 835 + const titleSpan = document.createElement('span'); 836 + titleSpan.textContent = title; 837 + titleSpan.style.flex = '1'; 838 + titleSpan.style.overflow = 'hidden'; 839 + titleSpan.style.textOverflow = 'ellipsis'; 840 + 841 + header.appendChild(favicon); 842 + header.appendChild(titleSpan); 843 + card.appendChild(header); 844 + 845 + // Subtitle in body 846 + const subtitleDiv = document.createElement('div'); 847 + subtitleDiv.textContent = subtitle; 848 + subtitleDiv.style.fontSize = '12px'; 849 + subtitleDiv.style.color = 'var(--base04)'; 850 + card.appendChild(subtitleDiv); 851 + 852 + // Tags in footer using peek-button-group 853 + if (tags.length > 0) { 854 + const footer = document.createElement('div'); 855 + footer.slot = 'footer'; 856 + footer.style.display = 'flex'; 857 + footer.style.flexWrap = 'wrap'; 858 + footer.style.gap = '4px'; 859 + 860 + tags.forEach(tag => { 861 + const tagChip = document.createElement('span'); 862 + tagChip.className = 'card-tag'; 863 + tagChip.textContent = tag.name; 864 + tagChip.style.padding = '2px 8px'; 865 + tagChip.style.fontSize = '11px'; 866 + tagChip.style.background = 'var(--base02)'; 867 + tagChip.style.borderRadius = '4px'; 868 + tagChip.style.cursor = 'pointer'; 869 + tagChip.dataset.tagId = tag.id; 870 + 871 + tagChip.addEventListener('click', (e) => { 872 + e.stopPropagation(); 873 + toggleTagFilter(tag); 874 + }); 875 + 876 + footer.appendChild(tagChip); 877 + }); 878 + 879 + card.appendChild(footer); 880 + } 881 + 882 + card.addEventListener('card-click', () => openEditModal(item)); 883 + 884 + return card; 885 + }; 886 + ``` 887 + 888 + #### Step 2.4: Update Edit Modal 889 + 890 + **Before:** 891 + ```javascript 892 + const openEditModal = (item) => { 893 + modalOverlay.classList.add('visible'); 894 + // Populate modal with innerHTML 895 + }; 896 + 897 + const closeModal = () => { 898 + modalOverlay.classList.remove('visible'); 899 + }; 900 + ``` 901 + 902 + **After:** 903 + ```javascript 904 + const openEditModal = (item) => { 905 + state.editingItem = item; 906 + 907 + const modal = document.querySelector('#editModal'); 908 + const tags = state.itemTags.get(item.id) || []; 909 + const { title, subtitle, faviconUrl } = getItemDisplayInfo(item); 910 + 911 + // Populate modal body (keep existing structure) 912 + const modalBody = modal.querySelector('.modal-body'); 913 + modalBody.innerHTML = ` 914 + <div class="modal-item-info"> 915 + <img class="modal-favicon" src="${faviconUrl}" alt=""> 916 + <div class="modal-item-details"> 917 + <div class="modal-item-title">${escapeHtml(title)}</div> 918 + <div class="modal-item-url">${escapeHtml(subtitle)}</div> 919 + </div> 920 + </div> 921 + <!-- Rest of modal content --> 922 + `; 923 + 924 + renderCurrentTags(tags); 925 + renderAvailableTags(tags); 926 + 927 + modal.show(); // or modal.showModal() for modal mode 928 + }; 929 + ``` 930 + 931 + #### Step 2.5: Update Filter Buttons 932 + 933 + Consider using `<peek-button-group>`: 934 + 935 + **Before:** 936 + ```javascript 937 + document.querySelectorAll('.filter-btn').forEach(btn => { 938 + btn.addEventListener('click', () => { 939 + const filter = btn.dataset.filter; 940 + state.activeFilter = state.activeFilter === filter ? 'all' : filter; 941 + render(); 942 + }); 943 + }); 944 + ``` 945 + 946 + **After (optional enhancement):** 947 + ```html 948 + <peek-button-group class="filter-icons" selection="single" variant="ghost"> 949 + <peek-button-group-item value="page"> 950 + <svg slot="prefix">...</svg> 951 + <span slot="suffix" class="filter-count">0</span> 952 + </peek-button-group-item> 953 + <!-- More items --> 954 + </peek-button-group> 955 + ``` 956 + 957 + ```javascript 958 + const filterGroup = document.querySelector('.filter-icons'); 959 + filterGroup.addEventListener('change', (e) => { 960 + state.activeFilter = e.detail.value || 'all'; 961 + render(); 962 + }); 963 + ``` 964 + 965 + ### Phase 3: Windows Extension 966 + 967 + **Goal:** Simplest migration - just cards and grid 968 + 969 + #### Step 3.1: Update HTML 970 + 971 + **Before:** 972 + ```html 973 + <div class="search-container"> 974 + <input type="text" class="search-input" placeholder="Search windows..."> 975 + </div> 976 + <main class="cards"></main> 977 + ``` 978 + 979 + **After:** 980 + ```html 981 + <script type="module"> 982 + import 'peek://app/components/peek-card.js'; 983 + import 'peek://app/components/peek-grid.js'; 984 + import 'peek://app/components/peek-input.js'; 985 + </script> 986 + 987 + <div class="search-container"> 988 + <peek-input 989 + class="search-input" 990 + placeholder="Search windows..." 991 + type="search" 992 + ></peek-input> 993 + </div> 994 + 995 + <peek-grid class="cards" min-item-width="200" gap="12" overlay></peek-grid> 996 + ``` 997 + 998 + Note: `overlay` attribute adds optimal spacing for full-screen overlays. 999 + 1000 + #### Step 3.2: Update Window Card Creation 1001 + 1002 + **Before:** 1003 + ```javascript 1004 + const createWindowCard = (win) => { 1005 + const card = document.createElement('div'); 1006 + card.className = 'card'; 1007 + 1008 + if (faviconUrl) { 1009 + const favicon = document.createElement('img'); 1010 + favicon.className = 'card-favicon'; 1011 + favicon.src = faviconUrl; 1012 + card.appendChild(favicon); 1013 + } 1014 + 1015 + const content = document.createElement('div'); 1016 + content.className = 'card-content'; 1017 + 1018 + const title = document.createElement('div'); 1019 + title.className = 'card-title'; 1020 + title.textContent = win.title || 'Untitled'; 1021 + 1022 + const url = document.createElement('div'); 1023 + url.className = 'card-url'; 1024 + url.textContent = win.url || ''; 1025 + 1026 + content.appendChild(title); 1027 + content.appendChild(url); 1028 + card.appendChild(content); 1029 + 1030 + card.addEventListener('click', async () => { 1031 + await api.window.focus(win.id); 1032 + closeWindowsView(); 1033 + }); 1034 + 1035 + return card; 1036 + }; 1037 + ``` 1038 + 1039 + **After:** 1040 + ```javascript 1041 + const createWindowCard = (win) => { 1042 + const card = document.createElement('peek-card'); 1043 + card.interactive = true; 1044 + card.elevated = true; 1045 + card.glass = true; // Use glass-morphism for overlay 1046 + 1047 + // Optional favicon 1048 + if (win.url && !win.url.startsWith('peek://')) { 1049 + try { 1050 + const url = new URL(win.url); 1051 + const favicon = document.createElement('img'); 1052 + favicon.slot = 'media'; 1053 + favicon.src = `${url.origin}/favicon.ico`; 1054 + favicon.alt = ''; 1055 + favicon.style.width = '48px'; 1056 + favicon.style.height = '48px'; 1057 + favicon.onerror = () => favicon.remove(); 1058 + card.appendChild(favicon); 1059 + } catch (e) { 1060 + // Invalid URL, skip favicon 1061 + } 1062 + } 1063 + 1064 + card.innerHTML += ` 1065 + <div slot="header">${escapeHtml(win.title || 'Untitled')}</div> 1066 + <div style="font-size: 12px; color: var(--peek-glass-text-secondary);"> 1067 + ${escapeHtml(win.url || '')} 1068 + </div> 1069 + `; 1070 + 1071 + card.addEventListener('card-click', async () => { 1072 + await api.window.focus(win.id); 1073 + closeWindowsView(); 1074 + }); 1075 + 1076 + return card; 1077 + }; 1078 + ``` 1079 + 1080 + --- 1081 + 1082 + ## Code Examples 1083 + 1084 + ### Complete Before/After: Groups Extension renderGroups() 1085 + 1086 + **Before:** 1087 + ```javascript 1088 + const renderGroups = () => { 1089 + const container = document.querySelector('.cards'); 1090 + container.innerHTML = ''; 1091 + 1092 + let allGroups = []; 1093 + if (state.untaggedCount > 0) { 1094 + allGroups.push({ ...UNTAGGED_GROUP, frequency: state.untaggedCount }); 1095 + } 1096 + const nonEmptyTags = state.tags.filter(tag => tag.addressCount > 0); 1097 + allGroups = allGroups.concat(nonEmptyTags); 1098 + 1099 + const filteredGroups = filterGroups(allGroups); 1100 + 1101 + if (filteredGroups.length === 0) { 1102 + const message = state.searchQuery 1103 + ? 'No groups match your search.' 1104 + : 'No groups yet. Tag some pages to create groups.'; 1105 + container.innerHTML = `<div class="empty-state">${message}</div>`; 1106 + return; 1107 + } 1108 + 1109 + filteredGroups.forEach(tag => { 1110 + const card = createGroupCard(tag); 1111 + container.appendChild(card); 1112 + }); 1113 + 1114 + state.selectedIndex = 0; 1115 + updateSelection(); 1116 + }; 1117 + ``` 1118 + 1119 + **After:** 1120 + ```javascript 1121 + const renderGroups = () => { 1122 + const container = document.querySelector('peek-grid.cards'); 1123 + container.innerHTML = ''; 1124 + 1125 + let allGroups = []; 1126 + if (state.untaggedCount > 0) { 1127 + allGroups.push({ ...UNTAGGED_GROUP, frequency: state.untaggedCount }); 1128 + } 1129 + const nonEmptyTags = state.tags.filter(tag => tag.addressCount > 0); 1130 + allGroups = allGroups.concat(nonEmptyTags); 1131 + 1132 + const filteredGroups = filterGroups(allGroups); 1133 + 1134 + if (filteredGroups.length === 0) { 1135 + const message = state.searchQuery 1136 + ? 'No groups match your search.' 1137 + : 'No groups yet. Tag some pages to create groups.'; 1138 + // Empty state can still be a regular div 1139 + const emptyState = document.createElement('div'); 1140 + emptyState.className = 'empty-state'; 1141 + emptyState.textContent = message; 1142 + emptyState.style.gridColumn = '1 / -1'; 1143 + container.appendChild(emptyState); 1144 + return; 1145 + } 1146 + 1147 + filteredGroups.forEach(tag => { 1148 + const card = createGroupCard(tag); 1149 + container.appendChild(card); 1150 + }); 1151 + 1152 + state.selectedIndex = 0; 1153 + updateSelection(); 1154 + }; 1155 + ``` 1156 + 1157 + ### Complete Before/After: Tags Extension Modal 1158 + 1159 + **Before:** 1160 + ```javascript 1161 + const openEditModal = (item) => { 1162 + state.editingItem = item; 1163 + 1164 + const modal = document.querySelector('.modal'); 1165 + const tags = state.itemTags.get(item.id) || []; 1166 + 1167 + // Set item info 1168 + modal.querySelector('.modal-favicon').src = faviconUrl; 1169 + modal.querySelector('.modal-item-title').textContent = title; 1170 + modal.querySelector('.modal-item-url').textContent = subtitle; 1171 + 1172 + renderCurrentTags(tags); 1173 + renderAvailableTags(tags); 1174 + 1175 + document.querySelector('.new-tag-input').value = ''; 1176 + 1177 + modalOverlay.classList.add('visible'); 1178 + }; 1179 + 1180 + const closeModal = () => { 1181 + modalOverlay.classList.remove('visible'); 1182 + state.editingItem = null; 1183 + }; 1184 + ``` 1185 + 1186 + **After:** 1187 + ```javascript 1188 + const openEditModal = (item) => { 1189 + state.editingItem = item; 1190 + 1191 + const modal = document.querySelector('#editModal'); 1192 + const tags = state.itemTags.get(item.id) || []; 1193 + const { title, subtitle, faviconUrl } = getItemDisplayInfo(item); 1194 + 1195 + // Update modal body 1196 + const body = modal.querySelector('.modal-body'); 1197 + body.querySelector('.modal-favicon').src = faviconUrl; 1198 + body.querySelector('.modal-item-title').textContent = title; 1199 + body.querySelector('.modal-item-url').textContent = subtitle; 1200 + 1201 + renderCurrentTags(tags); 1202 + renderAvailableTags(tags); 1203 + 1204 + body.querySelector('.new-tag-input').value = ''; 1205 + 1206 + modal.show(); // Use native dialog API 1207 + }; 1208 + 1209 + const closeModal = () => { 1210 + const modal = document.querySelector('#editModal'); 1211 + modal.close(); 1212 + state.editingItem = null; 1213 + }; 1214 + ``` 1215 + 1216 + ### Complete Example: New peek-card with All Features 1217 + 1218 + ```javascript 1219 + const createFullFeaturedCard = (item) => { 1220 + const card = document.createElement('peek-card'); 1221 + 1222 + // Properties 1223 + card.interactive = true; // Clickable 1224 + card.elevated = true; // Shadow 1225 + card.selected = false; // Selection state 1226 + card.glass = false; // Glass-morphism (for overlays) 1227 + 1228 + // Media slot (optional) 1229 + const media = document.createElement('img'); 1230 + media.slot = 'media'; 1231 + media.src = item.image; 1232 + media.alt = item.title; 1233 + card.appendChild(media); 1234 + 1235 + // Header slot 1236 + const header = document.createElement('div'); 1237 + header.slot = 'header'; 1238 + header.innerHTML = ` 1239 + <div style="display: flex; align-items: center; gap: 12px;"> 1240 + <img src="${item.favicon}" style="width: 24px; height: 24px;"> 1241 + <span>${escapeHtml(item.title)}</span> 1242 + </div> 1243 + `; 1244 + card.appendChild(header); 1245 + 1246 + // Body (default slot) 1247 + const body = document.createElement('div'); 1248 + body.innerHTML = `<p>${escapeHtml(item.description)}</p>`; 1249 + card.appendChild(body); 1250 + 1251 + // Footer slot 1252 + const footer = document.createElement('div'); 1253 + footer.slot = 'footer'; 1254 + footer.innerHTML = ` 1255 + <div style="display: flex; justify-content: space-between; align-items: center;"> 1256 + <span>${item.date}</span> 1257 + <div class="tags"> 1258 + ${item.tags.map(tag => `<span class="tag">${tag}</span>`).join('')} 1259 + </div> 1260 + </div> 1261 + `; 1262 + card.appendChild(footer); 1263 + 1264 + // Event handler 1265 + card.addEventListener('card-click', (e) => { 1266 + console.log('Card clicked:', item); 1267 + // e.detail.originalEvent contains the native click event 1268 + }); 1269 + 1270 + return card; 1271 + }; 1272 + ``` 1273 + 1274 + --- 1275 + 1276 + ## Missing Components & Feature Gaps 1277 + 1278 + ### Current Gaps 1279 + 1280 + 1. **No dedicated tag chip component** - Tags extension uses custom tag chips 1281 + - **Workaround:** Use `peek-button-group` with variant="ghost" for tag sets 1282 + - **Alternative:** Style `<span>` elements as chips with CSS 1283 + 1284 + 2. **No built-in empty state component** - All extensions show empty messages 1285 + - **Solution:** Create a simple div with `.empty-state` class 1286 + - **Future enhancement:** Add `peek-empty-state` component 1287 + 1288 + 3. **Grid keyboard navigation not built-in** - Manual hjkl navigation logic 1289 + - **Workaround:** Keep existing keyboard navigation code 1290 + - **Future enhancement:** Add grid navigation to `peek-grid` 1291 + 1292 + 4. **No favicon helper component** - Repetitive favicon creation 1293 + - **Solution:** Create a helper function or small component 1294 + - **Example:** 1295 + ```javascript 1296 + const createFavicon = (url, size = 32) => { 1297 + const img = document.createElement('img'); 1298 + img.src = url || 'data:image/svg+xml,...'; 1299 + img.alt = ''; 1300 + img.style.width = `${size}px`; 1301 + img.style.height = `${size}px`; 1302 + img.onerror = () => img.src = 'data:image/svg+xml,...'; 1303 + return img; 1304 + }; 1305 + ``` 1306 + 1307 + 5. **Modal footer actions not standardized** - Each extension handles differently 1308 + - **Solution:** Use `peek-dialog` with footer slot + `peek-button` 1309 + - **Pattern:** 1310 + ```html 1311 + <peek-dialog id="myDialog"> 1312 + <span slot="header">Title</span> 1313 + <div>Content</div> 1314 + <div slot="footer"> 1315 + <peek-button variant="ghost" onclick="myDialog.close()">Cancel</peek-button> 1316 + <peek-button variant="primary" onclick="save()">Save</peek-button> 1317 + </div> 1318 + </peek-dialog> 1319 + ``` 1320 + 1321 + ### Recommended New Components (Future) 1322 + 1323 + 1. **`peek-tag-chip`** - Reusable tag display with optional remove button 1324 + ```html 1325 + <peek-tag-chip removable @remove=${handleRemove}>work</peek-tag-chip> 1326 + ``` 1327 + 1328 + 2. **`peek-empty-state`** - Standardized empty state messaging 1329 + ```html 1330 + <peek-empty-state 1331 + icon="search" 1332 + message="No results found" 1333 + action="Clear search" 1334 + @action-click=${clearSearch} 1335 + ></peek-empty-state> 1336 + ``` 1337 + 1338 + 3. **`peek-grid` enhancements** - Add keyboard navigation prop 1339 + ```html 1340 + <peek-grid keyboard-nav="vim"> 1341 + <!-- Automatic hjkl navigation --> 1342 + </peek-grid> 1343 + ``` 1344 + 1345 + --- 1346 + 1347 + ## Testing Approach 1348 + 1349 + ### Test Plan Per Extension 1350 + 1351 + #### Groups Extension Tests 1352 + 1353 + 1. **Card Rendering** 1354 + - [ ] Group cards render with color dot, title, count 1355 + - [ ] Address cards render with favicon, title, URL, metadata 1356 + - [ ] Empty state shows when no groups 1357 + - [ ] Search filtering works 1358 + 1359 + 2. **Interactivity** 1360 + - [ ] Clicking group card navigates to addresses 1361 + - [ ] Clicking address card opens window 1362 + - [ ] peek-card `selected` state highlights correctly 1363 + - [ ] `card-click` event fires on interaction 1364 + 1365 + 3. **Layout** 1366 + - [ ] peek-grid renders responsive columns 1367 + - [ ] Gap and min-width respect properties 1368 + - [ ] Cards wrap correctly on resize 1369 + 1370 + 4. **Keyboard Navigation** 1371 + - [ ] hjkl navigation works 1372 + - [ ] Selection state updates 1373 + - [ ] Enter activates selected card 1374 + - [ ] Escape navigates back 1375 + 1376 + 5. **Search** 1377 + - [ ] peek-input value binding works 1378 + - [ ] Input events trigger filtering 1379 + - [ ] Placeholder text displays 1380 + 1381 + #### Tags Extension Tests 1382 + 1383 + 1. **Card Rendering** 1384 + - [ ] Item cards render with favicons and tags 1385 + - [ ] Tag chips in footer are clickable 1386 + - [ ] Different item types (page, text, tagset, image) render correctly 1387 + 1388 + 2. **Sidebar** 1389 + - [ ] peek-list renders tags with counts 1390 + - [ ] Multiple selection works 1391 + - [ ] Selection state persists 1392 + 1393 + 3. **Modal** 1394 + - [ ] peek-dialog opens with correct content 1395 + - [ ] Escape closes modal 1396 + - [ ] Backdrop click closes modal 1397 + - [ ] Tag editing works 1398 + 1399 + 4. **Filters** 1400 + - [ ] Type filter buttons work 1401 + - [ ] Active tag indicator shows selected tags 1402 + - [ ] Clearing filters works 1403 + 1404 + #### Windows Extension Tests 1405 + 1406 + 1. **Card Rendering** 1407 + - [ ] Window cards render with title and URL 1408 + - [ ] Optional favicons display 1409 + - [ ] Glass effect applies in overlay mode 1410 + 1411 + 2. **Interactivity** 1412 + - [ ] Clicking window card focuses window 1413 + - [ ] Window view closes after focus 1414 + 1415 + 3. **Layout** 1416 + - [ ] peek-grid overlay mode applies correct spacing 1417 + - [ ] Cards fill screen appropriately 1418 + 1419 + ### Visual Regression Tests 1420 + 1421 + Use Playwright or similar to capture screenshots: 1422 + 1423 + 1. **Baseline Screenshots** 1424 + - Capture current manual rendering 1425 + - Capture new peek-component rendering 1426 + - Compare for visual parity 1427 + 1428 + 2. **Test Scenarios** 1429 + - Empty state 1430 + - Single card 1431 + - Full grid 1432 + - Selected state 1433 + - Hover state (if testable) 1434 + - Search filtering results 1435 + 1436 + ### Manual Testing Checklist 1437 + 1438 + - [ ] All existing keyboard shortcuts work 1439 + - [ ] Performance is similar or better (card creation time) 1440 + - [ ] Memory usage is acceptable (check with DevTools) 1441 + - [ ] Theme tokens apply correctly 1442 + - [ ] Dark mode works 1443 + - [ ] Responsive behavior on small screens 1444 + - [ ] Accessibility: screen reader announces correctly 1445 + - [ ] Accessibility: keyboard focus indicators visible 1446 + 1447 + --- 1448 + 1449 + ## Rollout Strategy 1450 + 1451 + ### Phase 1: Proof of Concept (Week 1) 1452 + 1453 + 1. **Start with Windows Extension** (simplest) 1454 + - Migrate windows.html + windows.js 1455 + - Test thoroughly 1456 + - Validate approach 1457 + 1458 + 2. **Document Learnings** 1459 + - What worked well? 1460 + - What was harder than expected? 1461 + - Update migration plan accordingly 1462 + 1463 + ### Phase 2: Groups Extension (Week 2) 1464 + 1465 + 1. **Migrate Groups** 1466 + - Follow learnings from Windows 1467 + - Handle two-view navigation 1468 + - Test keyboard navigation thoroughly 1469 + 1470 + 2. **Refine Patterns** 1471 + - Create helper functions for common patterns 1472 + - Document best practices 1473 + 1474 + ### Phase 3: Tags Extension (Week 3) 1475 + 1476 + 1. **Migrate Tags** (most complex) 1477 + - Sidebar with peek-list 1478 + - Modal with peek-dialog 1479 + - Tag filtering 1480 + 1481 + 2. **Create Reusable Components** 1482 + - Extract tag chip pattern 1483 + - Extract favicon helper 1484 + 1485 + ### Phase 4: Polish & Optimization (Week 4) 1486 + 1487 + 1. **Performance Tuning** 1488 + - Profile card creation 1489 + - Optimize rendering 1490 + - Reduce re-renders 1491 + 1492 + 2. **Accessibility Audit** 1493 + - Test with screen readers 1494 + - Verify keyboard navigation 1495 + - Check ARIA labels 1496 + 1497 + 3. **Documentation** 1498 + - Update extension README files 1499 + - Document component usage patterns 1500 + - Create examples for future extensions 1501 + 1502 + ### Rollback Plan 1503 + 1504 + Each extension keeps existing code in commented-out form: 1505 + 1506 + ```javascript 1507 + // MIGRATION: Old manual rendering (for rollback) 1508 + /* 1509 + const createGroupCard_OLD = (tag) => { 1510 + // ... old code 1511 + }; 1512 + */ 1513 + 1514 + // New peek-component rendering 1515 + const createGroupCard = (tag) => { 1516 + // ... new code 1517 + }; 1518 + ``` 1519 + 1520 + To rollback: 1521 + 1. Rename new function to `_NEW` 1522 + 2. Uncomment old function 1523 + 3. Update callers 1524 + 4. Revert HTML changes 1525 + 1526 + --- 1527 + 1528 + ## Performance Considerations 1529 + 1530 + ### Potential Performance Impact 1531 + 1532 + 1. **Shadow DOM Overhead** 1533 + - Each peek-card creates a shadow root 1534 + - Mitigated by: Modern browsers optimize shadow DOM well 1535 + 1536 + 2. **Component Creation Cost** 1537 + - `customElements.define()` has initial cost 1538 + - Mitigated by: Lazy loading components only when needed 1539 + 1540 + 3. **Event Delegation** 1541 + - Custom events may have slight overhead 1542 + - Mitigated by: Minimal - composed events are efficient 1543 + 1544 + ### Optimizations 1545 + 1546 + 1. **Batch DOM Updates** 1547 + ```javascript 1548 + const fragment = document.createDocumentFragment(); 1549 + items.forEach(item => { 1550 + fragment.appendChild(createItemCard(item)); 1551 + }); 1552 + container.appendChild(fragment); 1553 + ``` 1554 + 1555 + 2. **Reuse Cards (Virtual Scrolling for Large Lists)** 1556 + - If rendering >100 cards, consider virtual scrolling 1557 + - peek-grid could be enhanced to support this 1558 + 1559 + 3. **Lazy Load Components** 1560 + ```javascript 1561 + // Only load when needed 1562 + if (!customElements.get('peek-dialog')) { 1563 + await import('peek://app/components/peek-dialog.js'); 1564 + } 1565 + ``` 1566 + 1567 + 4. **CSS Custom Properties Instead of Inline Styles** 1568 + ```javascript 1569 + // Instead of: 1570 + element.style.color = 'red'; 1571 + 1572 + // Use: 1573 + element.style.setProperty('--custom-color', 'red'); 1574 + ``` 1575 + 1576 + ### Performance Testing 1577 + 1578 + 1. **Measure Card Creation Time** 1579 + ```javascript 1580 + console.time('createCards'); 1581 + for (let i = 0; i < 100; i++) { 1582 + createGroupCard(mockTag); 1583 + } 1584 + console.timeEnd('createCards'); 1585 + ``` 1586 + 1587 + 2. **Memory Profiling** 1588 + - Use Chrome DevTools Memory Profiler 1589 + - Compare before/after heap snapshots 1590 + - Look for memory leaks 1591 + 1592 + 3. **Frame Rate Monitoring** 1593 + - Use Performance panel in DevTools 1594 + - Ensure scrolling stays at 60fps 1595 + - Profile during keyboard navigation 1596 + 1597 + --- 1598 + 1599 + ## Migration Checklist 1600 + 1601 + ### Groups Extension 1602 + 1603 + - [ ] Update HTML with component imports 1604 + - [ ] Replace `.cards` div with `peek-grid` 1605 + - [ ] Replace `.search-input` with `peek-input` 1606 + - [ ] Update `createGroupCard()` to use `peek-card` 1607 + - [ ] Update `createAddressCard()` to use `peek-card` 1608 + - [ ] Update `updateSelection()` to use card.selected property 1609 + - [ ] Update CSS to use component custom properties 1610 + - [ ] Remove obsolete CSS rules 1611 + - [ ] Test keyboard navigation 1612 + - [ ] Test two-view navigation 1613 + - [ ] Test search filtering 1614 + - [ ] Test escape key handling 1615 + - [ ] Visual regression test 1616 + - [ ] Accessibility test 1617 + 1618 + ### Tags Extension 1619 + 1620 + - [ ] Update HTML with component imports 1621 + - [ ] Replace `.cards` with `peek-grid` 1622 + - [ ] Replace `.search-input` with `peek-input` 1623 + - [ ] Replace `.tag-list` with `peek-list` 1624 + - [ ] Replace `.modal-overlay` with `peek-dialog` 1625 + - [ ] Update `createItemCard()` to use `peek-card` 1626 + - [ ] Update `renderTagSidebar()` to use `peek-list` 1627 + - [ ] Update `openEditModal()` to use dialog API 1628 + - [ ] Update filter buttons (optional: use `peek-button-group`) 1629 + - [ ] Update CSS for components 1630 + - [ ] Test sidebar tag selection 1631 + - [ ] Test modal open/close 1632 + - [ ] Test tag filtering 1633 + - [ ] Test item card rendering for all types 1634 + - [ ] Visual regression test 1635 + - [ ] Accessibility test 1636 + 1637 + ### Windows Extension 1638 + 1639 + - [ ] Update HTML with component imports 1640 + - [ ] Replace `.cards` div with `peek-grid overlay` 1641 + - [ ] Replace `.search-input` with `peek-input` 1642 + - [ ] Update `createWindowCard()` to use `peek-card glass` 1643 + - [ ] Update CSS for glass-morphism 1644 + - [ ] Test window focus behavior 1645 + - [ ] Test overlay mode layout 1646 + - [ ] Visual regression test 1647 + - [ ] Accessibility test 1648 + 1649 + --- 1650 + 1651 + ## Example: Complete Migrated Groups Extension 1652 + 1653 + ### groups/home.html 1654 + 1655 + ```html 1656 + <!DOCTYPE html> 1657 + <html> 1658 + <head> 1659 + <meta charset="utf-8"> 1660 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 1661 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 1662 + <title>Groups</title> 1663 + <link rel="stylesheet" type="text/css" href="home.css"> 1664 + 1665 + <script type="module"> 1666 + import 'peek://app/components/peek-card.js'; 1667 + import 'peek://app/components/peek-grid.js'; 1668 + import 'peek://app/components/peek-input.js'; 1669 + </script> 1670 + </head> 1671 + <body> 1672 + <div class="search-container"> 1673 + <peek-input 1674 + class="search-input" 1675 + placeholder="Search groups..." 1676 + type="search" 1677 + ></peek-input> 1678 + </div> 1679 + 1680 + <peek-grid class="cards" min-item-width="200" gap="12"></peek-grid> 1681 + 1682 + <script type="module" src="home.js"></script> 1683 + </body> 1684 + </html> 1685 + ``` 1686 + 1687 + ### groups/home.css (Updated) 1688 + 1689 + ```css 1690 + /* Import theme variables */ 1691 + @import url('peek://theme/variables.css'); 1692 + 1693 + * { 1694 + box-sizing: border-box; 1695 + margin: 0; 1696 + padding: 0; 1697 + } 1698 + 1699 + html { 1700 + font-family: var(--theme-font-sans); 1701 + -webkit-font-smoothing: antialiased; 1702 + font-size: 14px; 1703 + line-height: 1.5; 1704 + } 1705 + 1706 + body { 1707 + background: var(--base00); 1708 + color: var(--base05); 1709 + min-height: 100vh; 1710 + } 1711 + 1712 + /* Search */ 1713 + .search-container { 1714 + padding: 24px 24px 0 24px; 1715 + } 1716 + 1717 + /* Component customization */ 1718 + peek-input { 1719 + width: 100%; 1720 + --peek-input-bg: var(--base01); 1721 + --peek-input-border: var(--base02); 1722 + --peek-input-height: 44px; 1723 + } 1724 + 1725 + peek-input:focus-within { 1726 + --peek-input-border: var(--base0D); 1727 + } 1728 + 1729 + peek-grid { 1730 + padding: 24px; 1731 + --peek-grid-min-item-width: 200px; 1732 + --peek-grid-gap: 12px; 1733 + } 1734 + 1735 + peek-card { 1736 + --peek-card-bg: var(--base01); 1737 + --peek-card-border: transparent; 1738 + --peek-card-radius: 8px; 1739 + --peek-card-padding: 16px; 1740 + } 1741 + 1742 + peek-card[selected] { 1743 + --peek-card-bg: var(--base02); 1744 + --peek-card-border: var(--base0D); 1745 + } 1746 + 1747 + /* Empty state */ 1748 + .empty-state { 1749 + grid-column: 1 / -1; 1750 + text-align: center; 1751 + padding: 48px 24px; 1752 + color: var(--base03); 1753 + font-size: 15px; 1754 + } 1755 + 1756 + /* Custom styles for card content */ 1757 + .color-dot { 1758 + width: 12px; 1759 + height: 12px; 1760 + border-radius: 50%; 1761 + flex-shrink: 0; 1762 + } 1763 + 1764 + .card-favicon { 1765 + width: 32px; 1766 + height: 32px; 1767 + border-radius: 4px; 1768 + flex-shrink: 0; 1769 + background: var(--base02); 1770 + object-fit: contain; 1771 + } 1772 + ``` 1773 + 1774 + ### groups/home.js (Key Changes) 1775 + 1776 + ```javascript 1777 + /** 1778 + * Groups - Tag-based grouping of addresses 1779 + * Now using peek-component library for UI 1780 + */ 1781 + 1782 + const api = window.app; 1783 + const debug = api.debug; 1784 + 1785 + // ... (state, helpers unchanged) ... 1786 + 1787 + /** 1788 + * Create a card element for a group (tag) 1789 + * MIGRATED to use peek-card component 1790 + */ 1791 + const createGroupCard = (tag) => { 1792 + const card = document.createElement('peek-card'); 1793 + card.interactive = true; 1794 + card.elevated = true; 1795 + if (tag.isSpecial) { 1796 + card.classList.add('special-group'); 1797 + } 1798 + card.dataset.tagId = tag.id; 1799 + 1800 + // Header with color dot and name 1801 + const header = document.createElement('div'); 1802 + header.slot = 'header'; 1803 + header.style.display = 'flex'; 1804 + header.style.alignItems = 'center'; 1805 + header.style.gap = '12px'; 1806 + 1807 + const colorDot = document.createElement('div'); 1808 + colorDot.className = 'color-dot'; 1809 + colorDot.style.backgroundColor = tag.color || '#999'; 1810 + 1811 + const name = document.createElement('span'); 1812 + name.textContent = tag.name; 1813 + 1814 + header.appendChild(colorDot); 1815 + header.appendChild(name); 1816 + card.appendChild(header); 1817 + 1818 + // Footer with count 1819 + const footer = document.createElement('span'); 1820 + footer.slot = 'footer'; 1821 + const count = tag.isSpecial ? (tag.frequency || 0) : (tag.addressCount || 0); 1822 + footer.textContent = `${count} ${count === 1 ? 'page' : 'pages'}`; 1823 + card.appendChild(footer); 1824 + 1825 + // Click to view addresses in this group 1826 + card.addEventListener('card-click', () => showAddresses(tag)); 1827 + 1828 + return card; 1829 + }; 1830 + 1831 + /** 1832 + * Create a card element for an address 1833 + * MIGRATED to use peek-card component 1834 + */ 1835 + const createAddressCard = (address) => { 1836 + const card = document.createElement('peek-card'); 1837 + card.interactive = true; 1838 + card.elevated = true; 1839 + card.dataset.addressId = address.id; 1840 + 1841 + const addressUrl = address.uri || address.content; 1842 + 1843 + let displayTitle = address.title; 1844 + if (!displayTitle && address.metadata) { 1845 + try { 1846 + const meta = typeof address.metadata === 'string' ? JSON.parse(address.metadata) : address.metadata; 1847 + displayTitle = meta.title; 1848 + } catch (e) { 1849 + // Ignore parse errors 1850 + } 1851 + } 1852 + displayTitle = displayTitle || addressUrl; 1853 + 1854 + // Favicon in media slot 1855 + const favicon = document.createElement('img'); 1856 + favicon.slot = 'media'; 1857 + favicon.className = 'card-favicon'; 1858 + favicon.src = address.favicon || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 1859 + favicon.alt = ''; 1860 + favicon.onerror = () => { 1861 + favicon.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 1862 + }; 1863 + card.appendChild(favicon); 1864 + 1865 + // Header with title 1866 + const header = document.createElement('div'); 1867 + header.slot = 'header'; 1868 + header.textContent = displayTitle; 1869 + card.appendChild(header); 1870 + 1871 + // Body with URL 1872 + const url = document.createElement('div'); 1873 + url.textContent = addressUrl; 1874 + url.style.fontSize = '12px'; 1875 + url.style.color = 'var(--base04)'; 1876 + url.style.overflow = 'hidden'; 1877 + url.style.textOverflow = 'ellipsis'; 1878 + url.style.whiteSpace = 'nowrap'; 1879 + card.appendChild(url); 1880 + 1881 + // Footer with metadata 1882 + const footer = document.createElement('span'); 1883 + footer.slot = 'footer'; 1884 + const lastVisit = address.lastVisitAt ? new Date(address.lastVisitAt).toLocaleDateString() : 'Never'; 1885 + footer.textContent = `${address.visitCount || 0} visits · Last: ${lastVisit}`; 1886 + card.appendChild(footer); 1887 + 1888 + // Click to open address 1889 + card.addEventListener('card-click', async () => { 1890 + debug && console.log('Opening address:', addressUrl); 1891 + const result = await api.window.open(addressUrl, { 1892 + width: 800, 1893 + height: 600 1894 + }); 1895 + debug && console.log('Window opened:', result); 1896 + }); 1897 + 1898 + return card; 1899 + }; 1900 + 1901 + /** 1902 + * Update visual selection on cards 1903 + * UPDATED to use peek-card selected property 1904 + */ 1905 + const updateSelection = () => { 1906 + const cards = getCards(); 1907 + cards.forEach((card, i) => { 1908 + card.selected = i === state.selectedIndex; 1909 + }); 1910 + 1911 + const selected = cards[state.selectedIndex]; 1912 + if (selected) { 1913 + selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 1914 + } 1915 + }; 1916 + 1917 + /** 1918 + * Render groups cards 1919 + * UPDATED to use peek-grid 1920 + */ 1921 + const renderGroups = () => { 1922 + const container = document.querySelector('peek-grid.cards'); 1923 + container.innerHTML = ''; 1924 + 1925 + let allGroups = []; 1926 + if (state.untaggedCount > 0) { 1927 + allGroups.push({ ...UNTAGGED_GROUP, frequency: state.untaggedCount }); 1928 + } 1929 + 1930 + const nonEmptyTags = state.tags.filter(tag => tag.addressCount > 0); 1931 + allGroups = allGroups.concat(nonEmptyTags); 1932 + 1933 + const filteredGroups = filterGroups(allGroups); 1934 + 1935 + if (filteredGroups.length === 0) { 1936 + const message = state.searchQuery 1937 + ? 'No groups match your search.' 1938 + : 'No groups yet. Tag some pages to create groups.'; 1939 + const emptyState = document.createElement('div'); 1940 + emptyState.className = 'empty-state'; 1941 + emptyState.textContent = message; 1942 + container.appendChild(emptyState); 1943 + return; 1944 + } 1945 + 1946 + filteredGroups.forEach(tag => { 1947 + const card = createGroupCard(tag); 1948 + container.appendChild(card); 1949 + }); 1950 + 1951 + state.selectedIndex = 0; 1952 + updateSelection(); 1953 + }; 1954 + 1955 + // ... rest of code similar, with peek-input handling ... 1956 + 1957 + const init = async () => { 1958 + debug && console.log('Groups init'); 1959 + 1960 + api.escape.onEscape(handleEscape); 1961 + await loadTags(); 1962 + 1963 + // Set up search input (UPDATED for peek-input) 1964 + const searchInput = document.querySelector('peek-input.search-input'); 1965 + searchInput.addEventListener('input', (e) => { 1966 + state.searchQuery = searchInput.value; 1967 + renderCurrentView(); 1968 + }); 1969 + 1970 + // ... rest of init unchanged ... 1971 + 1972 + showGroups(); 1973 + }; 1974 + 1975 + document.addEventListener('DOMContentLoaded', init); 1976 + ``` 1977 + 1978 + --- 1979 + 1980 + ## Conclusion 1981 + 1982 + This migration plan provides a comprehensive roadmap for converting the Groups, Tags, and Windows extensions from manual DOM manipulation to using the Lit-based component library. The migration will: 1983 + 1984 + 1. **Reduce code duplication** - Shared card, grid, and input components 1985 + 2. **Improve maintainability** - Declarative component usage vs imperative DOM creation 1986 + 3. **Ensure consistency** - All extensions use same UI patterns 1987 + 4. **Enhance accessibility** - Built-in ARIA patterns and keyboard navigation 1988 + 5. **Simplify theming** - CSS custom properties for easy customization 1989 + 6. **Future-proof** - Easier to add new features and extensions 1990 + 1991 + The phased approach (Windows → Groups → Tags) allows for learning and refinement at each step, with clear rollback options if needed. The detailed code examples provide concrete guidance for implementation.
+1074
notes/research-userscripts.md
··· 1 + # Content Scripts and Userscripts System Design 2 + 3 + Comprehensive design for a Greasemonkey/Tampermonkey-compatible content scripts system for Peek, inspired by [quoid/userscripts](https://github.com/quoid/userscripts) Safari extension. 4 + 5 + **Last Updated:** 2026-02-09 6 + **Status:** Design/Planning Phase 7 + **Agent:** a9b4e5d 8 + 9 + --- 10 + 11 + ## 1. Executive Summary 12 + 13 + This document outlines a **content scripts/userscripts system** for Peek that enables users to write, manage, and execute JavaScript against web pages. The system combines: 14 + 15 + 1. **Metadata-driven execution** (Greasemonkey/Tampermonkey pattern) 16 + 2. **Integrated development environment** (editor extension integration) 17 + 3. **Datastore persistence** (consistent with Peek architecture) 18 + 4. **Dual execution modes** (Peek pages + web pages) 19 + 5. **Rich UI** (scripts list, code editor, live preview) 20 + 21 + ### Key Features 22 + 23 + - **Script Management**: Create, edit, enable/disable, import/export scripts 24 + - **Execution Engine**: Run scripts on matching URLs with timeout protection 25 + - **GM_* API Compatibility**: Basic Greasemonkey API layer (GM_getValue, GM_setValue, etc.) 26 + - **Three-Panel UI**: Scripts list (left), editor (center), preview/test (right) 27 + - **Background Automation**: Scheduled execution with cron-like scheduling 28 + - **Import/Export**: Support for .user.js format (Greasemonkey/Tampermonkey) 29 + 30 + --- 31 + 32 + ## 2. Reference Implementation: quoid/userscripts 33 + 34 + ### 2.1 Analysis of quoid/userscripts 35 + 36 + **Key Observations**: 37 + - Clean three-panel layout: script list, editor, settings 38 + - Metadata-driven script matching (`@match`, `@exclude-match`) 39 + - Support for standard Greasemonkey headers 40 + - Simple enable/disable toggles per script 41 + - Import/export as `.user.js` files 42 + - Execution contexts: document-start, document-end, document-idle 43 + - Basic GM_* API compatibility 44 + 45 + **What We'll Adopt**: 46 + - Three-panel UI pattern (adapted to Peek's editor extension) 47 + - Metadata-driven matching system 48 + - Import/export .user.js format 49 + - Execution timing controls (run-at) 50 + 51 + **What We'll Improve**: 52 + - Live preview panel showing execution results 53 + - Integration with Peek's datastore for persistence 54 + - Scheduled background execution 55 + - Execution history and analytics 56 + - Test mode with URL input 57 + 58 + --- 59 + 60 + ## 3. Architecture Overview 61 + 62 + ### 3.1 Core Components 63 + 64 + ``` 65 + ┌─────────────────────────────────────────────────────────────┐ 66 + │ Scripts Extension │ 67 + ├─────────────────────────────────────────────────────────────┤ 68 + │ │ 69 + │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ 70 + │ │ Scripts │ │ Script │ │ Background │ │ 71 + │ │ Manager UI │ │ Executor │ │ Scheduler │ │ 72 + │ │ │ │ │ │ │ │ 73 + │ │ - List │ │ - Pattern │ │ - Cron-like │ │ 74 + │ │ - CRUD ops │ │ - Injection │ │ - Auto-exec │ │ 75 + │ │ - Import/ │ │ - Timeout │ │ - History │ │ 76 + │ │ Export │ │ - GM_* API │ │ │ │ 77 + │ └──────────────┘ └──────────────┘ └─────────────────┘ │ 78 + │ │ 79 + ├─────────────────────────────────────────────────────────────┤ 80 + │ Datastore Tables │ 81 + ├─────────────────────────────────────────────────────────────┤ 82 + │ - scripts: Script metadata (name, code, match patterns) │ 83 + │ - scripts_data: Execution history and results │ 84 + └─────────────────────────────────────────────────────────────┘ 85 + ``` 86 + 87 + ### 3.2 Data Flow 88 + 89 + 1. **Script Creation**: User creates script via UI → saved to `scripts` table 90 + 2. **Page Load**: Peek opens URL → executor checks match patterns → runs matching scripts 91 + 3. **Manual Execution**: User clicks "Test on This Page" → executor runs against test URL 92 + 4. **Scheduled Execution**: Background scheduler checks intervals → runs matching scripts 93 + 5. **Result Storage**: Execution completes → result logged to `scripts_data` table 94 + 95 + --- 96 + 97 + ## 4. User Interface Design 98 + 99 + ### 4.1 Three-Panel Layout 100 + 101 + ``` 102 + ┌────────────────────────────────────────────────────────────────────────┐ 103 + │ Scripts Manager [Minimize] [_] [x] │ 104 + ├────────────────────────────────────────────────────────────────────────┤ 105 + │ │ 106 + │ ┌─────────────┐ ┌──────────────────────────┐ ┌─────────────────────┐ │ 107 + │ │ Scripts │ │ Editor │ │ Preview / Tests │ │ 108 + │ │ │ │ │ │ │ │ 109 + │ │ [+] New │ │ Name: ___________ │ │ Test URL: │ │ 110 + │ │ │ │ Match: ___________ │ │ https://example.com │ │ 111 + │ │ ✓ Script 1 │ │ Run-at: [document-end] ▼│ │ │ │ 112 + │ │ 24ms │ │ │ │ [Test on This Page] │ │ 113 + │ │ 2h ago │ │ ┌──────────────────────┐ │ │ │ │ 114 + │ │ │ │ │ 1 // ==UserScript== │ │ │ ┌─────────────────┐ │ │ 115 + │ │ ✗ Script 2 │ │ │ 2 // @name Test │ │ │ │ Status: Success │ │ │ 116 + │ │ ERROR │ │ │ 3 // │ │ │ │ Time: 42ms │ │ │ 117 + │ │ Never │ │ │ 4 │ │ │ │ │ │ │ 118 + │ │ │ │ │ 5 let h = doc... │ │ │ │ Result: │ │ │ 119 + │ │ ◊ Script 3 │ │ │ 6 return h │ │ │ │ { │ │ │ 120 + │ │ disabled │ │ │ │ │ │ │ "data": [..], │ │ │ 121 + │ │ 42ms │ │ │ │ │ │ │ "count": 15 │ │ │ 122 + │ │ │ │ └──────────────────────┘ │ │ │ } │ │ │ 123 + │ │ ⓘ Script 4 │ │ [Save] [Revert] [Delete] │ │ │ │ │ │ 124 + │ │ no change │ │ │ │ │ [Copy Result] │ │ │ 125 + │ │ 15min ago │ │ │ │ │ [Copy JSON] │ │ │ 126 + │ │ │ │ │ │ │ │ │ │ 127 + │ │ ⚙ Settings │ │ │ │ └─────────────────┘ │ │ 128 + │ │ 📝 History │ │ │ │ │ │ 129 + │ │ ⓘ About │ │ │ │ Execution History: │ │ 130 + │ │ │ │ │ │ ┌─────────────────┐ │ │ 131 + │ │ [RUN ALL] │ │ │ │ │ Latest 5 Runs: │ │ │ 132 + │ │ │ │ │ │ │ ✓ 2m ago │ │ │ 133 + │ │ │ │ │ │ │ ✓ 1h ago │ │ │ 134 + │ │ │ │ │ │ │ ✓ 1d ago │ │ │ 135 + │ │ │ │ │ │ │ ✗ 3d ago │ │ │ 136 + │ │ │ │ │ │ │ ✓ 1w ago │ │ │ 137 + │ │ │ │ │ │ └─────────────────┘ │ │ 138 + │ └─────────────┘ └──────────────────────────┘ └─────────────────────┘ │ 139 + │ │ 140 + └────────────────────────────────────────────────────────────────────────┘ 141 + ``` 142 + 143 + ### 4.2 Script List Features 144 + 145 + Each script shows: 146 + - **Checkbox** (enable/disable) 147 + - **Icon** (type: web-scraper, data-transform, automation, utility) 148 + - **Name** + truncated description 149 + - **Status indicator**: 150 + - Green checkmark: Last execution successful 151 + - Red X: Last execution failed 152 + - Orange warning: Last execution but data unchanged 153 + - Gray: Never executed 154 + - **Execution time badge** (e.g., "24ms") 155 + - **Last run timestamp** (e.g., "2 hours ago") 156 + 157 + Right-click context menu: 158 + - Edit 159 + - Delete 160 + - Duplicate 161 + - Export as JSON 162 + - View history 163 + - Debug (dev console for this script) 164 + 165 + ### 4.3 Script Creation Workflow 166 + 167 + 1. **Click [+ New]** → Opens new script editor 168 + 2. **Set metadata**: 169 + - Name: "My Data Scraper" 170 + - Match patterns: `https://news.example.com/*` 171 + - Run-at: document-end 172 + 3. **Write code**: 173 + ```javascript 174 + // Extract all headlines 175 + const headlines = Array.from( 176 + document.querySelectorAll('h2.headline') 177 + ).map(el => el.textContent); 178 + 179 + console.log('Found headlines:', headlines); 180 + 181 + // Return data to Peek 182 + { headlines, count: headlines.length } 183 + ``` 184 + 4. **Test on Page**: Enter test URL, hit "Test on This Page" 185 + 5. **Save** → Stored in datastore, ready for auto-execution 186 + 187 + ### 4.4 Preview Panel States 188 + 189 + **When no test has been run:** 190 + ``` 191 + Test URL: 192 + [https://___________________] 193 + 194 + [Test on This Page] 195 + 196 + Instructions: 197 + 1. Enter a URL to test 198 + 2. Click "Test on This Page" 199 + 3. Results will appear here 200 + ``` 201 + 202 + **During execution:** 203 + ``` 204 + Testing: https://example.com 205 + ⏳ Running... (2s) 206 + ``` 207 + 208 + **After success:** 209 + ``` 210 + ✓ Success 211 + Execution time: 342ms 212 + 213 + Result: 214 + { 215 + "headlines": [ 216 + "Story 1", 217 + "Story 2" 218 + ], 219 + "count": 15 220 + } 221 + 222 + [Copy Result] [Copy JSON] 223 + 224 + ↓ Changed from last run 225 + Previous: count: 12 226 + ``` 227 + 228 + **After error:** 229 + ``` 230 + ✗ Error at line 5 231 + 232 + TypeError: Cannot read property 'querySelectorAll' of undefined 233 + 234 + Stack: 235 + at Object.<anonymous> (eval:5:13) 236 + at runScript (executor.js:45:12) 237 + 238 + [Show Full Error] 239 + ``` 240 + 241 + --- 242 + 243 + ## 5. Script Execution Engine 244 + 245 + ### 5.1 Execution Modes 246 + 247 + **Two Execution Modes**: 248 + 249 + 1. **Peek Pages** (peek://ext/*, peek://app/*): 250 + - Direct execution in renderer process 251 + - Full DOM access, no sandboxing needed 252 + - Used for testing/preview 253 + 254 + 2. **Web Pages** (https://*, http://*): 255 + - Injected as `<script>` tag into page 256 + - Runs in page context (not isolated) 257 + - Can be upgraded to isolated context with `function(){}` wrapper 258 + - Communicates back via postMessage to Peek IPC 259 + 260 + ### 5.2 Script Executor Class 261 + 262 + ```javascript 263 + // extensions/scripts/script-executor.js 264 + 265 + export class ScriptExecutor { 266 + /** 267 + * Execute a script against a page URL or DOM 268 + */ 269 + async executeScript(script, executionContext) { 270 + const { 271 + url, // Full URL of target page 272 + pageDOM, // document object (if peek:// page) 273 + pageWindow, // window object (if peek:// page) 274 + timeout = 5000 // Max execution time 275 + } = executionContext; 276 + 277 + // Validate script matches URL 278 + if (!this.matchesUrl(script, url)) { 279 + return { 280 + status: 'skipped', 281 + reason: 'URL does not match script patterns' 282 + }; 283 + } 284 + 285 + try { 286 + const result = await this.runScriptInContext( 287 + script.code, 288 + pageDOM || document, 289 + pageWindow || window, 290 + timeout 291 + ); 292 + 293 + return { 294 + status: 'success', 295 + result, 296 + executionTime: result.time, 297 + output: result.output 298 + }; 299 + } catch (error) { 300 + return { 301 + status: 'error', 302 + error: error.message, 303 + stack: error.stack 304 + }; 305 + } 306 + } 307 + 308 + /** 309 + * Check if script's match patterns apply to URL 310 + */ 311 + matchesUrl(script, url) { 312 + return script.matchPatterns.some(pattern => 313 + this.matchPattern(pattern, url) 314 + ) && !script.excludePatterns.some(pattern => 315 + this.matchPattern(pattern, url) 316 + ); 317 + } 318 + 319 + /** 320 + * Match a single pattern against URL 321 + * Supports: https://example.com/*, *://example.com/*, etc. 322 + */ 323 + matchPattern(pattern, url) { 324 + // Convert glob pattern to regex 325 + const regex = new RegExp( 326 + '^' + pattern 327 + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars 328 + .replace(/\*/g, '.*') // * -> .* 329 + .replace(/\?/g, '.') // ? -> . 330 + + '$' 331 + ); 332 + return regex.test(url); 333 + } 334 + 335 + /** 336 + * Execute script with timeout, capturing console output 337 + */ 338 + async runScriptInContext(code, document, window, timeout) { 339 + const startTime = performance.now(); 340 + const logs = []; 341 + 342 + // Capture console output 343 + const originalLog = console.log; 344 + console.log = (...args) => { 345 + logs.push(args.map(a => 346 + typeof a === 'object' ? JSON.stringify(a) : String(a) 347 + ).join(' ')); 348 + originalLog(...args); 349 + }; 350 + 351 + try { 352 + // Wrap in async function to support await 353 + const wrappedCode = ` 354 + (async () => { 355 + ${code} 356 + })() 357 + `; 358 + 359 + const fn = new Function('document', 'window', wrappedCode); 360 + const result = await Promise.race([ 361 + fn(document, window), 362 + new Promise((_, reject) => 363 + setTimeout(() => reject(new Error('Script timeout')), timeout) 364 + ) 365 + ]); 366 + 367 + return { 368 + time: Math.round(performance.now() - startTime), 369 + output: logs, 370 + result 371 + }; 372 + } finally { 373 + console.log = originalLog; 374 + } 375 + } 376 + } 377 + ``` 378 + 379 + ### 5.3 GM_* API Compatibility Layer 380 + 381 + Provide a minimal GM_* compatibility layer: 382 + 383 + ```javascript 384 + // extensions/scripts/gm-api.js 385 + 386 + export function createGMAPI(scriptId, namespace) { 387 + const store = openStore(`gm_${scriptId}`, {}); 388 + 389 + return { 390 + // Storage APIs (match Greasemonkey) 391 + GM_setValue: async (key, value) => { 392 + store.set(key, value); 393 + }, 394 + 395 + GM_getValue: async (key, defaultValue) => { 396 + return store.get(key) ?? defaultValue; 397 + }, 398 + 399 + GM_deleteValue: async (key) => { 400 + store.delete(key); 401 + }, 402 + 403 + // Logging 404 + GM_log: (...args) => { 405 + console.log('[' + scriptId + ']', ...args); 406 + }, 407 + 408 + // Notifications (via Peek API) 409 + GM_notification: async (text, title, options) => { 410 + // Could integrate with Peek notifications if available 411 + console.log(title, text); 412 + }, 413 + 414 + // Network (via IPC to avoid CORS) 415 + GM_xmlhttpRequest: async (options) => { 416 + const result = await fetch(options.url, { 417 + method: options.method || 'GET', 418 + headers: options.headers, 419 + body: options.data 420 + }); 421 + options.onload?.({ 422 + responseText: await result.text(), 423 + status: result.status 424 + }); 425 + } 426 + }; 427 + } 428 + ``` 429 + 430 + Inject into script context: 431 + 432 + ```javascript 433 + const fn = new Function('document', 'window', 'GM_getValue', 'GM_setValue', ... 434 + wrappedCode 435 + ); 436 + const result = await fn(document, window, gmApi.GM_getValue, gmApi.GM_setValue, ...); 437 + ``` 438 + 439 + --- 440 + 441 + ## 6. Background Script Automation 442 + 443 + ### 6.1 Auto-Execution Triggers 444 + 445 + Scripts can be triggered by: 446 + 447 + 1. **On Page Load** 448 + - When Peek opens a web page URL 449 + - Check match patterns 450 + - Execute matching scripts in background 451 + 452 + 2. **On Scheduled Interval** 453 + ```json 454 + { 455 + "schedule": "hourly" | "daily" | "weekly" | "custom", 456 + "customSchedule": "0 9 * * *" // cron format (optional) 457 + } 458 + ``` 459 + - Maintained by background extension 460 + - Stores last execution in scripts_data table 461 + 462 + 3. **Via Command Palette** 463 + - User invokes "scripts:run" with URL parameter 464 + - Can pick specific script or run all matching 465 + 466 + 4. **Manual Trigger** (user clicks [RUN] button) 467 + 468 + ### 6.2 Background Execution Model 469 + 470 + ```javascript 471 + // extensions/scripts/background.js (in existing background script context) 472 + 473 + const backgroundExecutor = { 474 + async runScheduledScripts() { 475 + const store = openStore('scripts', {}); 476 + const scripts = store.get('scripts') || []; 477 + 478 + for (const script of scripts) { 479 + if (!script.enabled || !script.schedule) continue; 480 + 481 + const lastRun = script.lastScheduledRun || 0; 482 + const interval = this.getScheduleInterval(script.schedule); 483 + 484 + if (Date.now() - lastRun >= interval) { 485 + // Run on a sample of recent pages matching pattern 486 + const recentPages = await this.getRecentPagesMatching(script); 487 + 488 + for (const page of recentPages) { 489 + await scriptEngine.executeScript(script, { 490 + url: page.uri, 491 + pageWindow: null, // No window context (headless) 492 + pageDOM: null // Would need to fetch page content 493 + }); 494 + } 495 + 496 + script.lastScheduledRun = Date.now(); 497 + store.set('scripts', scripts); 498 + } 499 + } 500 + }, 501 + 502 + getScheduleInterval(schedule) { 503 + const intervals = { 504 + 'hourly': 60 * 60 * 1000, 505 + 'daily': 24 * 60 * 60 * 1000, 506 + 'weekly': 7 * 24 * 60 * 60 * 1000 507 + }; 508 + return intervals[schedule] || 0; 509 + }, 510 + 511 + async getRecentPagesMatching(script) { 512 + // Query datastore for recent addresses matching pattern 513 + const result = await api.datastore.queryAddresses({ 514 + sortBy: 'lastVisit', 515 + limit: 5 516 + }); 517 + 518 + return result.data.filter(addr => 519 + scriptEngine.matchesUrl(script, addr.uri) 520 + ); 521 + } 522 + }; 523 + 524 + // Run scheduled checks periodically 525 + setInterval(() => { 526 + backgroundExecutor.runScheduledScripts(); 527 + }, 60 * 1000); // Every minute, check if any scripts need running 528 + ``` 529 + 530 + --- 531 + 532 + ## 7. Data Schema & Storage 533 + 534 + ### 7.1 Script Metadata Storage 535 + 536 + Each script is stored with: 537 + 538 + ```javascript 539 + { 540 + id: 'script_1707500000000_abc123', 541 + name: 'HN Headline Scraper', 542 + description: 'Extract all headlines from Hacker News', 543 + code: '... JavaScript source ...', 544 + 545 + // Execution configuration 546 + matchPatterns: [ 547 + 'https://news.ycombinator.com/*', 548 + 'https://hn.algolia.com/*' 549 + ], 550 + excludePatterns: [], 551 + runAt: 'document-end', 552 + 553 + // Triggers 554 + enabled: true, 555 + schedule: null, // or 'hourly', 'daily', etc. 556 + autoExecute: true, // Run when matching page loads 557 + 558 + // Metadata 559 + version: '1.0.0', 560 + author: 'User Name', 561 + namespace: 'https://peek.local/user/scripts/hn-scraper', 562 + 563 + // Statistics 564 + createdAt: 1707500000000, 565 + updatedAt: 1707500000000, 566 + lastExecutedAt: 1707500000000, 567 + executionCount: 45, 568 + lastError: null, 569 + 570 + // Storage 571 + metadata: '{"grants": ["GM_getValue"], "connects": ["api.example.com"]}' 572 + } 573 + ``` 574 + 575 + ### 7.2 Execution History 576 + 577 + Each execution logged to `scripts_data`: 578 + 579 + ```javascript 580 + { 581 + id: 'script_data_1707500000000_def456', 582 + scriptId: 'script_1707500000000_abc123', 583 + addressId: 'addr_xyz789', // URL where executed 584 + 585 + status: 'success', // 'success', 'error', 'timeout' 586 + error: null, 587 + executedAt: 1707500000000, 588 + executionTime: 342, // milliseconds 589 + 590 + result: '{"headlines": ["...", "..."], "count": 15}', 591 + extractedData: '{"headlines": [array], "count": 15}', 592 + output: '["Found 15 headlines"]', // console.log output 593 + 594 + changed: true, // Did result differ from last execution? 595 + previousResult: '{"headlines": ["..."], "count": 12}' 596 + } 597 + ``` 598 + 599 + ### 7.3 Querying & Analytics 600 + 601 + Example queries on scripts_data: 602 + 603 + ```javascript 604 + // Script statistics 605 + async function getScriptStats(scriptId) { 606 + const data = await api.datastore.getTable('scripts_data'); 607 + const scriptExecutions = Object.values(data).filter( 608 + row => row.scriptId === scriptId 609 + ); 610 + 611 + return { 612 + totalExecutions: scriptExecutions.length, 613 + successCount: scriptExecutions.filter(r => r.status === 'success').length, 614 + errorCount: scriptExecutions.filter(r => r.status === 'error').length, 615 + averageTime: scriptExecutions.reduce((sum, r) => sum + r.executionTime, 0) / 616 + scriptExecutions.length, 617 + lastRun: scriptExecutions[scriptExecutions.length - 1]?.executedAt 618 + }; 619 + } 620 + ``` 621 + 622 + --- 623 + 624 + ## 8. Integration with Editor Extension 625 + 626 + The scripts system works closely with the editor extension: 627 + 628 + ### 8.1 Opening Scripts in Editor 629 + 630 + ```javascript 631 + // From scripts manager (right-click on script) 632 + api.publish('editor:open', { 633 + itemId: script.id, // Identifies this as a script 634 + content: script.code, 635 + file: `script_${script.id}.js`, 636 + metadata: { 637 + type: 'userscript', 638 + name: script.name, 639 + matchPatterns: script.matchPatterns, 640 + runAt: script.runAt 641 + } 642 + }, api.scopes.GLOBAL); 643 + ``` 644 + 645 + ### 8.2 Saving from Editor 646 + 647 + When editor saves a script: 648 + 649 + ```javascript 650 + // In editor extension 651 + api.subscribe('scripts:save', async (msg) => { 652 + const { scriptId, code, metadata } = msg; 653 + 654 + // Update script in datastore 655 + const script = await api.datastore.getRow('scripts', scriptId); 656 + script.code = code; 657 + Object.assign(script, metadata); 658 + script.updatedAt = Date.now(); 659 + 660 + await api.datastore.setRow('scripts', scriptId, script); 661 + 662 + // Notify scripts extension 663 + api.publish('script:updated', { scriptId }, api.scopes.GLOBAL); 664 + }, api.scopes.GLOBAL); 665 + ``` 666 + 667 + ### 8.3 Side-by-Side Editing + Preview 668 + 669 + When editing a script: 670 + 671 + 1. **Left**: Script list sidebar (minimized or hidden) 672 + 2. **Center**: CodeMirror editor (from editor extension) 673 + 3. **Right**: Live preview panel showing: 674 + - Test URL input 675 + - [Test on This Page] button 676 + - Execution results in real-time 677 + 678 + --- 679 + 680 + ## 9. Import/Export & Sharing 681 + 682 + ### 9.1 Export Formats 683 + 684 + **Single Script (JSON)**: 685 + ```json 686 + { 687 + "type": "peek-userscript", 688 + "version": "1.0.0", 689 + "script": { 690 + "name": "My Script", 691 + "code": "...", 692 + "matchPatterns": ["https://example.com/*"], 693 + "runAt": "document-end" 694 + } 695 + } 696 + ``` 697 + 698 + **Multiple Scripts (ZIP)**: 699 + ``` 700 + scripts-export.zip 701 + ├── script-1.js 702 + ├── script-2.js 703 + ├── metadata.json 704 + └── README.md 705 + ``` 706 + 707 + **Greasemonkey/Tampermonkey Format**: 708 + Direct `.user.js` file with UserScript header: 709 + ```javascript 710 + // ==UserScript== 711 + // @name Example 712 + // @match https://example.com/* 713 + // ==/UserScript== 714 + ... code ... 715 + ``` 716 + 717 + ### 9.2 Import Process 718 + 719 + 1. Drag `.user.js` file onto scripts manager 720 + 2. Parse UserScript header 721 + 3. Extract metadata (name, match, run-at, etc.) 722 + 4. Show import preview dialog 723 + 5. Confirm → Create script in datastore 724 + 725 + ```javascript 726 + async function importUserScript(fileContent) { 727 + const { header, code } = parseUserScriptHeader(fileContent); 728 + 729 + const script = { 730 + id: generateId('script'), 731 + name: header['@name'] || 'Imported Script', 732 + description: header['@description'] || '', 733 + code, 734 + matchPatterns: header['@match'] || ['*://*/*'], 735 + excludePatterns: header['@exclude'] || [], 736 + runAt: header['@run-at'] || 'document-end', 737 + enabled: true, 738 + createdAt: Date.now(), 739 + updatedAt: Date.now() 740 + }; 741 + 742 + await api.datastore.setRow('scripts', script.id, script); 743 + return script; 744 + } 745 + ``` 746 + 747 + --- 748 + 749 + ## 10. Example Scripts 750 + 751 + ### Example 1: HN Headline Scraper 752 + 753 + ```javascript 754 + // ==UserScript== 755 + // @name HN Headline Scraper 756 + // @description Extract all stories from Hacker News homepage 757 + // @match https://news.ycombinator.com/* 758 + // @run-at document-end 759 + // ==/UserScript== 760 + 761 + const stories = []; 762 + const rows = document.querySelectorAll('.athing'); 763 + 764 + rows.forEach((row, index) => { 765 + const titleEl = row.querySelector('.titleline > a'); 766 + const scoreEl = row.nextElementSibling?.querySelector('.score'); 767 + 768 + if (titleEl) { 769 + stories.push({ 770 + rank: index + 1, 771 + title: titleEl.textContent, 772 + url: titleEl.href, 773 + score: scoreEl ? parseInt(scoreEl.textContent) : null 774 + }); 775 + } 776 + }); 777 + 778 + console.log(`Found ${stories.length} stories`); 779 + return { stories, timestamp: new Date().toISOString() }; 780 + ``` 781 + 782 + ### Example 2: Price Change Detector 783 + 784 + ```javascript 785 + // ==UserScript== 786 + // @name Price Tracker 787 + // @description Monitor product price changes 788 + // @match https://example-shop.com/product/* 789 + // @run-at document-end 790 + // ==/UserScript== 791 + 792 + const priceEl = document.querySelector('[data-price]'); 793 + const price = parseFloat(priceEl?.dataset.price || '0'); 794 + 795 + const GM_getValue = window.GM_getValue || (() => null); 796 + const previousPrice = await GM_getValue('lastPrice'); 797 + 798 + const changed = previousPrice && Math.abs(previousPrice - price) > 0.01; 799 + 800 + if (changed) { 801 + const direction = price < previousPrice ? 'decreased' : 'increased'; 802 + console.log(`Price ${direction}: $${previousPrice} → $${price}`); 803 + } 804 + 805 + // Store current price for next run 806 + if (window.GM_setValue) { 807 + await GM_setValue('lastPrice', price); 808 + } 809 + 810 + return { price, changed, direction: price < previousPrice ? 'down' : 'up' }; 811 + ``` 812 + 813 + ### Example 3: Form Auto-Fill 814 + 815 + ```javascript 816 + // ==UserScript== 817 + // @name Auto-Fill Helper 818 + // @description Auto-fill common form fields 819 + // @match https://forms.example.com/* 820 + // @run-at document-start 821 + // ==/UserScript== 822 + 823 + // Run before form renders (with document-start) 824 + window.addEventListener('load', () => { 825 + const fields = { 826 + email: document.querySelector('input[name="email"]'), 827 + phone: document.querySelector('input[name="phone"]'), 828 + name: document.querySelector('input[name="name"]') 829 + }; 830 + 831 + if (fields.email) fields.email.value = 'user@example.com'; 832 + if (fields.phone) fields.phone.value = '555-1234'; 833 + if (fields.name) fields.name.value = 'John Doe'; 834 + 835 + console.log('Form fields auto-filled'); 836 + return { filled: Object.keys(fields).filter(k => fields[k]).length }; 837 + }); 838 + ``` 839 + 840 + --- 841 + 842 + ## 11. Security Considerations 843 + 844 + ### 11.1 Threat Model 845 + 846 + 1. **Untrusted User Scripts** 847 + - User imports script from internet 848 + - Could exfiltrate data, modify pages, etc. 849 + 850 + **Mitigation**: 851 + - Show source code before import 852 + - Display requested permissions (@grant, @connect) 853 + - User explicitly approves execution 854 + - Log all executions for audit 855 + 856 + 2. **Malicious Pattern Matching** 857 + - Script with `<all_urls>` runs on every page 858 + - Could be memory/CPU intensive 859 + 860 + **Mitigation**: 861 + - Timeout protection (5 second default) 862 + - Rate limiting (max X executions per minute) 863 + - Memory limits (kill if exceeds threshold) 864 + - Disable scripts after repeated errors 865 + 866 + 3. **Data Exfiltration** 867 + - Script extracts sensitive info, sends to attacker server 868 + 869 + **Mitigation**: 870 + - Log all network requests made by scripts 871 + - Show network log in debug panel 872 + - Future: Network request approval dialog (like permissions) 873 + 874 + ### 11.2 Recommended Security Features 875 + 876 + 1. **Script Signing** (future): 877 + - Author signs script with private key 878 + - Peek verifies signature on import 879 + - Builds trust network of script authors 880 + 881 + 2. **Permissions Model** (future): 882 + - `@grant GM_*` APIs explicitly listed 883 + - `@connect` domains declared 884 + - User prompted on first use 885 + 886 + 3. **Isolation Options** (future): 887 + - iframe sandbox mode for sensitive sites 888 + - Service worker isolation alternative 889 + 890 + --- 891 + 892 + ## 12. Implementation Roadmap 893 + 894 + ### Phase 1: Core System (2-3 weeks) 895 + 896 + - [ ] Create `scripts` extension 897 + - [ ] Implement script datastore schema 898 + - [ ] Build script executor (content script injection) 899 + - [ ] Create basic scripts manager UI (list + simple editor) 900 + - [ ] Test execution on both peek:// and http:// URLs 901 + 902 + ### Phase 2: Full Editor Integration (1-2 weeks) 903 + 904 + - [ ] Integrate with editor extension 905 + - [ ] CodeMirror setup with syntax highlighting 906 + - [ ] Metadata form (name, match patterns, run-at) 907 + - [ ] Test/preview panel 908 + - [ ] Execution result display 909 + 910 + ### Phase 3: Advanced Features (2-3 weeks) 911 + 912 + - [ ] Import/export (Tampermonkey/Greasemonkey format) 913 + - [ ] Scheduled execution (cron-like) 914 + - [ ] Script history & analytics 915 + - [ ] GM_* API compatibility layer 916 + - [ ] Debugging console for scripts 917 + 918 + ### Phase 4: Polish & Refinement (1 week) 919 + 920 + - [ ] Keyboard shortcuts 921 + - [ ] Context menus (right-click on scripts) 922 + - [ ] Error handling & recovery 923 + - [ ] Documentation & examples 924 + - [ ] Performance optimization 925 + 926 + --- 927 + 928 + ## 13. Key Architectural Decisions 929 + 930 + ### Decision 1: Where to Execute Scripts? 931 + 932 + **Chosen: Two-mode approach** 933 + - **Peek pages** (peek://): Direct execution in renderer 934 + - **Web pages** (https://): Injected script + IPC bridge 935 + 936 + **Alternative Considered**: Always fetch page content via HTTP and execute headless (CORS issues, slower) 937 + 938 + ### Decision 2: Sandboxing Level? 939 + 940 + **Chosen: Minimal sandboxing initially** 941 + - Scripts can access full page DOM 942 + - Can make XMLHttpRequest/fetch 943 + - No iframe isolation (complexity vs. safety tradeoff) 944 + 945 + **Future**: Add iframe sandbox mode for untrusted scripts with permission model 946 + 947 + ### Decision 3: Storage Location? 948 + 949 + **Chosen: TinyBase datastore (like addresses/visits)** 950 + - Consistent with Peek architecture 951 + - Queries via datastore API 952 + - Can be synced to server in future 953 + 954 + **Alternative Considered**: Separate file-based storage (worse sync prospects) 955 + 956 + ### Decision 4: Script Testing? 957 + 958 + **Chosen: Live preview against real URLs** 959 + - User enters test URL 960 + - Script executes against that URL's content 961 + - Results show immediately in right panel 962 + 963 + **Alternative**: Static preview mode (less useful, doesn't catch real-world issues) 964 + 965 + --- 966 + 967 + ## 14. Testing Strategy 968 + 969 + ### Unit Tests 970 + 971 + ```javascript 972 + // tests/scripts/executor.spec.js 973 + 974 + describe('ScriptExecutor', () => { 975 + test('matchPattern: exact domain', () => { 976 + const executor = new ScriptExecutor(); 977 + expect(executor.matchPattern('https://example.com/*', 'https://example.com/page')) 978 + .toBe(true); 979 + expect(executor.matchPattern('https://example.com/*', 'https://other.com/page')) 980 + .toBe(false); 981 + }); 982 + 983 + test('matchPattern: wildcard subdomain', () => { 984 + const executor = new ScriptExecutor(); 985 + expect(executor.matchPattern('https://*.example.com/*', 'https://sub.example.com/')) 986 + .toBe(true); 987 + expect(executor.matchPattern('https://*.example.com/*', 'https://example.com/')) 988 + .toBe(false); // Subdomains only 989 + }); 990 + 991 + test('executeScript: timeout protection', async () => { 992 + const executor = new ScriptExecutor(); 993 + const result = await executor.executeScript( 994 + { code: 'while(true) {}' }, 995 + { url: 'https://example.com', timeout: 100 } 996 + ); 997 + expect(result.status).toBe('error'); 998 + expect(result.error).toContain('timeout'); 999 + }); 1000 + }); 1001 + ``` 1002 + 1003 + ### Integration Tests 1004 + 1005 + ```javascript 1006 + // tests/scripts/integration.spec.js 1007 + 1008 + describe('Scripts Extension', () => { 1009 + test('Create, save, and execute script', async () => { 1010 + const api = window.app; 1011 + 1012 + // Create script 1013 + const script = { 1014 + id: 'test_script', 1015 + name: 'Test', 1016 + code: 'return 42;', 1017 + matchPatterns: ['https://example.com/*'], 1018 + runAt: 'document-end' 1019 + }; 1020 + 1021 + await api.datastore.setRow('scripts', script.id, script); 1022 + 1023 + // Execute 1024 + const result = await scriptEngine.executeScript(script, { 1025 + url: 'https://example.com/', 1026 + pageDOM: document 1027 + }); 1028 + 1029 + expect(result.status).toBe('success'); 1030 + expect(result.result).toBe(42); 1031 + }); 1032 + }); 1033 + ``` 1034 + 1035 + --- 1036 + 1037 + ## 15. Success Metrics 1038 + 1039 + - Script creation workflow < 2 minutes 1040 + - Execution latency < 500ms for typical scripts 1041 + - Ability to import any Greasemonkey/Tampermonkey script 1042 + - Support for 10,000+ lines of code per script 1043 + - Error handling with clear stack traces 1044 + 1045 + --- 1046 + 1047 + ## 16. Why It's Valuable for Peek 1048 + 1049 + 1. **Extends Peek's "workbench" vision** - Scripts are automation + data mining tools 1050 + 2. **Bridges to web automation** - Users can extract data from any website 1051 + 3. **Compatible with web standards** - Supports Greasemonkey/Tampermonkey format 1052 + 4. **Fits extension architecture** - Extends existing extension system cleanly 1053 + 5. **Enables future features**: 1054 + - Scheduled data collection 1055 + - Custom domain-specific tools 1056 + - Workflow automation 1057 + - Data transformation pipelines 1058 + 1059 + --- 1060 + 1061 + ## Conclusion 1062 + 1063 + This comprehensive design provides a **powerful, accessible content scripting system** that fits naturally into Peek's modular architecture while maintaining compatibility with the established web scripting ecosystem. The three-panel UI approach inspired by quoid/userscripts, combined with Peek's datastore persistence and editor integration, creates a unique development environment for web automation and data extraction. 1064 + 1065 + The phased implementation prioritizes core functionality first, with advanced features (scheduling, GM_* APIs, security) following once the foundation is solid. 1066 + 1067 + --- 1068 + 1069 + ## Meta 1070 + 1071 + - **Author**: Exploration Agent (a9b4e5d) 1072 + - **Date**: 2026-02-09 1073 + - **Status**: Design/Planning Phase 1074 + - **Related**: Extension system, editor extension, datastore architecture, web automation
+97 -6
notes/research-widgets-hud.md
··· 66 66 67 67 Full widget infrastructure for Peek. Widgets are the building blocks for HUDs, dashboards, page metadata panels, command previews, and observability displays. 68 68 69 + ### Integration with Peek Component Framework 70 + 71 + Widgets should be built using the existing Lit component system in `app/components/`. This provides: 72 + 73 + - **Base Classes**: `PeekElement` (base class with shared styles and utilities) and `DataBoundElement` (adds reactive data binding) 74 + - **Available Components**: peek-card, peek-grid, peek-list, peek-button, peek-carousel, peek-details, peek-tabs, peek-dialog, peek-drawer, peek-dropdown, and more 75 + - **Data Binding**: Reactive patterns via `data-binding.js` with support for signals, observables, and static data 76 + - **Theme Integration**: Automatic via CSS custom properties (design tokens in `base.js`) 77 + - **Schema Validation**: Built-in JSON Schema validation for widget data 78 + 79 + **Widget Type to Component Mapping:** 80 + - Scalar widget → `peek-card` with single value in body slot 81 + - List widget → `peek-list` with items 82 + - Table widget → `peek-grid` with data binding 83 + - Timeline/Chart → custom `peek-card` with visualization in body 84 + - Gauge → `peek-card` with progress indicator 85 + - Carousel → `peek-carousel` with widget cards 86 + - Stats → `peek-card` with key/value pairs in body 87 + 88 + **Design Principle**: Widget API should extend/compose existing components rather than reinvent. Widgets inherit from `DataBoundElement` to get reactive data binding automatically. 89 + 90 + **Example: Scalar Widget** 91 + ```javascript 92 + import { html } from 'lit'; 93 + import { DataBoundElement } from './components/data-binding.js'; 94 + 95 + class ScalarWidget extends DataBoundElement { 96 + static properties = { 97 + label: { type: String } 98 + }; 99 + 100 + static dataSchema = { 101 + type: 'object', 102 + properties: { 103 + value: { type: 'number' }, 104 + unit: { type: 'string' } 105 + } 106 + }; 107 + 108 + render() { 109 + return html` 110 + <peek-card> 111 + <div slot="header">${this.label}</div> 112 + <div slot="body" class="scalar-value"> 113 + ${this.data.value} 114 + <span class="unit">${this.data.unit}</span> 115 + </div> 116 + </peek-card> 117 + `; 118 + } 119 + } 120 + 121 + // Usage with reactive data source 122 + const windowCount = signal({ value: 5, unit: 'windows' }); 123 + const widget = document.createElement('scalar-widget'); 124 + widget.label = 'Open Windows'; 125 + widget.bindTo(windowCount); 126 + ``` 127 + 69 128 ### Widget API 70 129 71 - - `api.widgets.register(type, renderer)` — register a widget type with its renderer 72 - - `api.widgets.create(type, config)` — instantiate a widget 130 + - `api.widgets.register(type, componentClass)` — register a widget type with its Lit component class 131 + - `api.widgets.create(type, config)` — instantiate a widget (creates component and configures data binding) 73 132 - `api.widgets.sheets` — manage widget sheets (collections of widgets) 133 + 134 + Widget components extend `DataBoundElement` base class for automatic reactive updates via Lit's property/state system. 74 135 75 136 ### Widget Types 76 137 ··· 85 146 86 147 ### Implementation Phases 87 148 88 - 1. **Foundation** — Widget API, database storage for widget configs, basic scalar/list/table types 89 - 2. **Visualization** — Timeline, chart, gauge types, template system 149 + 1. **Foundation** — Widget API, database storage for widget configs, basic scalar/list/table types using existing peek-card/peek-list/peek-grid 150 + 2. **Visualization** — Timeline, chart, gauge types using custom peek-card compositions, Lit template system 90 151 3. **Integration** — Extension API for publishing widgets, sheet management 91 152 4. **Polish** — No-code widget creation path, drag-and-drop layout 92 153 ··· 96 157 97 158 ### Template System 98 159 99 - Widgets bind to data sources and re-render reactively. Callers provide schema + data, widget has default template that can be overridden. 160 + Widgets use Lit's html tagged template literals for rendering. Reactive updates via Lit's property/state system and `DataBoundElement.bindTo()` method. Components support: 161 + 162 + - **Slots**: Customization points (like peek-card's header/body/footer slots) 163 + - **Data Binding**: `bindTo(source)` connects to signals, observables, or static data 164 + - **Schema Validation**: Automatic via `dataSchema` property 165 + - **Reactive Updates**: Lit handles DOM updates when data changes 166 + 167 + **Example: List Widget** 168 + ```javascript 169 + class ListWidget extends DataBoundElement { 170 + static dataSchema = { 171 + type: 'object', 172 + properties: { 173 + items: { 174 + type: 'array', 175 + items: { type: 'string' } 176 + } 177 + } 178 + }; 179 + 180 + render() { 181 + return html` 182 + <peek-list> 183 + ${this.data.items?.map(item => html` 184 + <div slot="item">${item}</div> 185 + `)} 186 + </peek-list> 187 + `; 188 + } 189 + } 190 + ``` 100 191 101 192 ### Relationship to UI Componentry 102 193 103 - The Widgets system builds on the UI Componentry work outlined in TODO.md (cards, grids, carousels, button sets). Widgets are the data-bound, reactive layer on top of those primitives. 194 + The Widgets system builds directly on the component framework in `app/components/`. Widgets are the data-bound, reactive layer that composes existing peek-* primitives (cards, grids, lists, carousels, buttons) with schema-validated data sources. 104 195 105 196 ## HUD (Always-On-Top Overlay) 106 197
+286
tests/desktop/hud.spec.ts
··· 1 + /** 2 + * HUD Extension Tests 3 + * 4 + * Tests the always-on-top HUD overlay that shows mode, IZUI state, and window info. 5 + */ 6 + 7 + import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 8 + import { Page } from '@playwright/test'; 9 + import { waitForExtensionsReady, sleep } from '../helpers/window-utils'; 10 + 11 + // Shared app for tests 12 + let sharedApp: DesktopApp; 13 + let sharedBgWindow: Page; 14 + 15 + // Helper to toggle HUD 16 + const toggleHUD = async () => { 17 + await sharedBgWindow.evaluate(async () => { 18 + (window as any).app.publish('cmd:execute:hud', {}, (window as any).app.scopes.GLOBAL); 19 + }); 20 + await sleep(500); 21 + }; 22 + 23 + test.beforeAll(async () => { 24 + sharedApp = await getSharedApp(); 25 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 26 + await waitForExtensionsReady(sharedBgWindow); 27 + }); 28 + 29 + test.afterAll(async () => { 30 + await closeSharedApp(); 31 + }); 32 + 33 + test.describe('HUD Extension @desktop', () => { 34 + test('HUD extension loads', async () => { 35 + // Verify HUD extension is registered 36 + const extensions = await sharedBgWindow.evaluate(async () => { 37 + const exts = (window as any).app.extensions.list(); 38 + return exts.data || []; 39 + }); 40 + 41 + const hudExt = extensions.find((ext: any) => ext.id === 'hud'); 42 + expect(hudExt).toBeTruthy(); 43 + expect(hudExt.name).toBe('HUD'); 44 + expect(hudExt.builtin).toBe(true); 45 + }); 46 + 47 + test('hud command is registered', async () => { 48 + // Wait a bit to ensure commands are registered 49 + await sleep(1000); 50 + 51 + const commands = await sharedBgWindow.evaluate(async () => { 52 + return (window as any).app.commands.list(); 53 + }); 54 + 55 + const hudCmd = commands.find((cmd: any) => cmd.name === 'hud'); 56 + expect(hudCmd).toBeTruthy(); 57 + expect(hudCmd.description).toContain('HUD'); 58 + }); 59 + 60 + test('toggle HUD via command', async () => { 61 + // Toggle HUD to open 62 + await toggleHUD(); 63 + 64 + // Find HUD window 65 + const hudWindow = await sharedApp.getWindow('ext/hud/hud.html', 5000); 66 + expect(hudWindow).toBeTruthy(); 67 + 68 + // Verify HUD container is visible 69 + await hudWindow.waitForSelector('#hud-container', { timeout: 5000 }); 70 + const container = await hudWindow.$('#hud-container'); 71 + expect(container).toBeTruthy(); 72 + 73 + // Toggle to close 74 + await toggleHUD(); 75 + }); 76 + 77 + test('HUD displays mode information', async () => { 78 + // Open HUD 79 + await toggleHUD(); 80 + 81 + const hudWindow = await sharedApp.getWindow('ext/hud/hud.html', 5000); 82 + await hudWindow.waitForSelector('#mode-value', { timeout: 5000 }); 83 + 84 + // Get initial mode value 85 + const modeValue = await hudWindow.$eval('#mode-value', el => el.textContent); 86 + expect(modeValue).toBeTruthy(); 87 + expect(['default', 'page', 'group', 'settings']).toContain(modeValue); 88 + 89 + // Change mode 90 + await sharedBgWindow.evaluate(async () => { 91 + return await (window as any).app.context.setMode('page', { url: 'https://example.com' }); 92 + }); 93 + 94 + await sleep(500); 95 + 96 + // Verify mode updated in HUD 97 + const newModeValue = await hudWindow.$eval('#mode-value', el => el.textContent); 98 + expect(newModeValue).toBe('page'); 99 + 100 + // Verify mode color class is applied 101 + const modeClasses = await hudWindow.$eval('#mode-value', el => el.className); 102 + expect(modeClasses).toContain('mode-page'); 103 + 104 + // Clean up 105 + await sharedBgWindow.evaluate(async () => { 106 + return await (window as any).app.context.setMode('default'); 107 + }); 108 + 109 + await sharedBgWindow.evaluate(async () => { 110 + return await (window as any).app.commands.execute('hud'); 111 + }); 112 + }); 113 + 114 + test('HUD displays IZUI state', async () => { 115 + // Open HUD 116 + await toggleHUD(); 117 + 118 + const hudWindow = await sharedApp.getWindow('ext/hud/hud.html', 5000); 119 + await hudWindow.waitForSelector('#izui-value', { timeout: 5000 }); 120 + 121 + // Get IZUI state value 122 + const izuiValue = await hudWindow.$eval('#izui-value', el => el.textContent); 123 + expect(izuiValue).toBeTruthy(); 124 + expect(['idle', 'transient', 'active', 'overlay']).toContain(izuiValue); 125 + 126 + // Verify state color class is applied 127 + const izuiClasses = await hudWindow.$eval('#izui-value', el => el.className); 128 + expect(izuiClasses).toMatch(/izui-(idle|transient|active|overlay)/); 129 + 130 + // Clean up - close HUD 131 + await toggleHUD(); 132 + }); 133 + 134 + test('HUD displays window count', async () => { 135 + // Open HUD 136 + await toggleHUD(); 137 + 138 + const hudWindow = await sharedApp.getWindow('ext/hud/hud.html', 5000); 139 + await hudWindow.waitForSelector('#stats-value', { timeout: 5000 }); 140 + 141 + // Get window count 142 + const statsText = await hudWindow.$eval('#stats-value', el => el.textContent); 143 + expect(statsText).toBeTruthy(); 144 + expect(statsText).toMatch(/\d+ windows?/); 145 + 146 + // Verify count is a reasonable number (at least 1, no more than 100) 147 + const match = statsText?.match(/(\d+) windows?/); 148 + expect(match).toBeTruthy(); 149 + const count = parseInt(match![1], 10); 150 + expect(count).toBeGreaterThanOrEqual(1); 151 + expect(count).toBeLessThanOrEqual(100); 152 + 153 + // Clean up - close HUD 154 + await toggleHUD(); 155 + }); 156 + 157 + test('HUD window has correct properties', async () => { 158 + // Open HUD 159 + await toggleHUD(); 160 + 161 + // Get window list and find HUD window 162 + const windows = await sharedBgWindow.evaluate(async () => { 163 + const result = await (window as any).app.window.list(); 164 + return result.data || []; 165 + }); 166 + 167 + const hudWindowInfo = windows.find((w: any) => w.url?.includes('ext/hud/hud.html')); 168 + expect(hudWindowInfo).toBeTruthy(); 169 + 170 + // Verify window properties (these should be set in background.js) 171 + // Note: exact property names may vary by backend 172 + expect(hudWindowInfo.url).toContain('ext/hud/hud.html'); 173 + 174 + // Clean up - close HUD 175 + await toggleHUD(); 176 + }); 177 + 178 + test('HUD persists enabled state', async () => { 179 + // Enable HUD 180 + await toggleHUD(); 181 + 182 + // Verify HUD is open 183 + let hudWindow = await sharedApp.getWindow('ext/hud/hud.html', 5000); 184 + expect(hudWindow).toBeTruthy(); 185 + 186 + // Check localStorage state 187 + const enabledState = await sharedBgWindow.evaluate(async () => { 188 + // Get the HUD extension's background window 189 + const windows = await (window as any).app.window.list(); 190 + const hudBgWindow = windows.data?.find((w: any) => w.url?.includes('hud/background.html')); 191 + 192 + if (!hudBgWindow) return null; 193 + 194 + // Can't directly access localStorage from another window, so we'll trust the command works 195 + return 'enabled'; // If window exists, state is enabled 196 + }); 197 + 198 + expect(enabledState).toBe('enabled'); 199 + 200 + // Disable HUD for clean state 201 + await toggleHUD(); 202 + }); 203 + 204 + test('HUD updates reactively on mode changes', async () => { 205 + // Open HUD 206 + await toggleHUD(); 207 + 208 + const hudWindow = await sharedApp.getWindow('ext/hud/hud.html', 5000); 209 + await hudWindow.waitForSelector('#mode-value', { timeout: 5000 }); 210 + 211 + // Set to default mode 212 + await sharedBgWindow.evaluate(async () => { 213 + return await (window as any).app.context.setMode('default'); 214 + }); 215 + 216 + await sleep(500); 217 + 218 + let modeValue = await hudWindow.$eval('#mode-value', el => el.textContent); 219 + expect(modeValue).toBe('default'); 220 + 221 + // Change to page mode 222 + await sharedBgWindow.evaluate(async () => { 223 + return await (window as any).app.context.setMode('page', { url: 'https://test.com' }); 224 + }); 225 + 226 + await sleep(500); 227 + 228 + modeValue = await hudWindow.$eval('#mode-value', el => el.textContent); 229 + expect(modeValue).toBe('page'); 230 + 231 + // Change to settings mode 232 + await sharedBgWindow.evaluate(async () => { 233 + return await (window as any).app.context.setMode('settings'); 234 + }); 235 + 236 + await sleep(500); 237 + 238 + modeValue = await hudWindow.$eval('#mode-value', el => el.textContent); 239 + expect(modeValue).toBe('settings'); 240 + 241 + // Clean up 242 + await sharedBgWindow.evaluate(async () => { 243 + return await (window as any).app.context.setMode('default'); 244 + }); 245 + 246 + await sharedBgWindow.evaluate(async () => { 247 + return await (window as any).app.commands.execute('hud'); 248 + }); 249 + }); 250 + 251 + test('HUD displays group mode with name', async () => { 252 + // Open HUD 253 + await toggleHUD(); 254 + 255 + const hudWindow = await sharedApp.getWindow('ext/hud/hud.html', 5000); 256 + await hudWindow.waitForSelector('#mode-value', { timeout: 5000 }); 257 + 258 + // Set to group mode with metadata 259 + await sharedBgWindow.evaluate(async () => { 260 + return await (window as any).app.context.setMode('group', { 261 + groupId: 'test-group-123', 262 + groupName: 'Test Group' 263 + }); 264 + }); 265 + 266 + await sleep(500); 267 + 268 + // Verify group name is displayed 269 + const modeValue = await hudWindow.$eval('#mode-value', el => el.textContent); 270 + expect(modeValue).toContain('group'); 271 + expect(modeValue).toContain('Test Group'); 272 + 273 + // Verify group color class is applied 274 + const modeClasses = await hudWindow.$eval('#mode-value', el => el.className); 275 + expect(modeClasses).toContain('mode-group'); 276 + 277 + // Clean up 278 + await sharedBgWindow.evaluate(async () => { 279 + return await (window as any).app.context.setMode('default'); 280 + }); 281 + 282 + await sharedBgWindow.evaluate(async () => { 283 + return await (window as any).app.commands.execute('hud'); 284 + }); 285 + }); 286 + });
+209
tests/desktop/smoke.spec.ts
··· 4150 4150 }); 4151 4151 }); 4152 4152 }); 4153 + 4154 + // ============================================================================ 4155 + // Scripts Extension Tests (uses shared app) 4156 + // ============================================================================ 4157 + 4158 + test.describe('Scripts Extension @desktop', () => { 4159 + test('create, save, and execute script', async () => { 4160 + // Wait for scripts extension to be ready 4161 + await waitForExtensionsReady(sharedBgWindow, 15000); 4162 + 4163 + // Create a new script directly via datastore 4164 + const scriptId = await sharedBgWindow.evaluate(async () => { 4165 + const api = (window as any).app; 4166 + const scriptId = `script_test_${Date.now()}`; 4167 + 4168 + // Get current settings from datastore 4169 + const settingsTable = await api.datastore.getTable('extension_settings'); 4170 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 4171 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 4172 + 4173 + // Add new script 4174 + const newScript = { 4175 + id: scriptId, 4176 + name: 'Test Script', 4177 + description: 'A test script', 4178 + code: 'const h1 = document.querySelector("h1"); return { title: h1?.textContent || "No h1 found" };', 4179 + matchPatterns: ['https://example.com/*'], 4180 + excludePatterns: [], 4181 + runAt: 'document-end', 4182 + enabled: true, 4183 + createdAt: Date.now(), 4184 + updatedAt: Date.now(), 4185 + lastExecutedAt: null 4186 + }; 4187 + 4188 + scripts.push(newScript); 4189 + 4190 + // Save back to datastore 4191 + await api.datastore.setRow('extension_settings', 'scripts:scripts', { 4192 + extensionId: 'scripts', 4193 + key: 'scripts', 4194 + value: JSON.stringify(scripts), 4195 + updatedAt: Date.now() 4196 + }); 4197 + 4198 + return scriptId; 4199 + }); 4200 + 4201 + expect(scriptId).toBeTruthy(); 4202 + 4203 + // Verify script was saved 4204 + const savedScript = await sharedBgWindow.evaluate(async (scriptId) => { 4205 + const api = (window as any).app; 4206 + const settingsTable = await api.datastore.getTable('extension_settings'); 4207 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 4208 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 4209 + return scripts.find((s: any) => s.id === scriptId); 4210 + }, scriptId); 4211 + 4212 + expect(savedScript).toBeTruthy(); 4213 + expect(savedScript.name).toBe('Test Script'); 4214 + 4215 + // Execute script - test executor directly 4216 + const executeResult = await sharedBgWindow.evaluate(async (scriptId) => { 4217 + const api = (window as any).app; 4218 + const { scriptExecutor } = await import('peek://ext/scripts/script-executor.js'); 4219 + 4220 + // Get the script from datastore 4221 + const settingsTable = await api.datastore.getTable('extension_settings'); 4222 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 4223 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 4224 + const script = scripts.find((s: any) => s.id === scriptId); 4225 + 4226 + if (!script) { 4227 + return { success: false, error: 'Script not found' }; 4228 + } 4229 + 4230 + // Execute directly 4231 + const result = await scriptExecutor.executeScript(script, { 4232 + url: 'https://example.com/test', 4233 + pageDOM: document, 4234 + pageWindow: window 4235 + }); 4236 + 4237 + return { success: true, data: result }; 4238 + }, scriptId); 4239 + 4240 + expect(executeResult).toHaveProperty('success', true); 4241 + expect((executeResult as any).data.status).toBe('success'); 4242 + 4243 + // Clean up - delete script 4244 + await sharedBgWindow.evaluate(async (scriptId) => { 4245 + const api = (window as any).app; 4246 + const settingsTable = await api.datastore.getTable('extension_settings'); 4247 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 4248 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 4249 + const filtered = scripts.filter((s: any) => s.id !== scriptId); 4250 + await api.datastore.setRow('extension_settings', 'scripts:scripts', { 4251 + extensionId: 'scripts', 4252 + key: 'scripts', 4253 + value: JSON.stringify(filtered), 4254 + updatedAt: Date.now() 4255 + }); 4256 + }, scriptId); 4257 + }); 4258 + 4259 + test('script pattern matching works', async () => { 4260 + // Test pattern matching directly 4261 + const patternTests = await sharedBgWindow.evaluate(async () => { 4262 + // Import the script executor module 4263 + const { ScriptExecutor } = await import('peek://ext/scripts/script-executor.js'); 4264 + const executor = new ScriptExecutor(); 4265 + 4266 + return { 4267 + exactMatch: executor.matchPattern('https://example.com/*', 'https://example.com/page'), 4268 + noMatch: executor.matchPattern('https://example.com/*', 'https://other.com/page'), 4269 + wildcardProtocol: executor.matchPattern('*://example.com/*', 'https://example.com/page'), 4270 + wildcardAll: executor.matchPattern('*', 'https://anything.com/page') 4271 + }; 4272 + }); 4273 + 4274 + expect(patternTests.exactMatch).toBe(true); 4275 + expect(patternTests.noMatch).toBe(false); 4276 + expect(patternTests.wildcardProtocol).toBe(true); 4277 + expect(patternTests.wildcardAll).toBe(true); 4278 + }); 4279 + 4280 + test('script timeout protection works', async () => { 4281 + // Create a script that runs forever 4282 + const scriptId = await sharedBgWindow.evaluate(async () => { 4283 + const api = (window as any).app; 4284 + const scriptId = `script_timeout_test_${Date.now()}`; 4285 + 4286 + // Get current settings from datastore 4287 + const settingsTable = await api.datastore.getTable('extension_settings'); 4288 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 4289 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 4290 + 4291 + // Add timeout test script 4292 + const newScript = { 4293 + id: scriptId, 4294 + name: 'Timeout Test', 4295 + code: 'while(true) {}', // Infinite loop 4296 + matchPatterns: ['*'], 4297 + excludePatterns: [], 4298 + runAt: 'document-end', 4299 + enabled: true, 4300 + createdAt: Date.now(), 4301 + updatedAt: Date.now(), 4302 + lastExecutedAt: null 4303 + }; 4304 + 4305 + scripts.push(newScript); 4306 + await api.datastore.setRow('extension_settings', 'scripts:scripts', { 4307 + extensionId: 'scripts', 4308 + key: 'scripts', 4309 + value: JSON.stringify(scripts), 4310 + updatedAt: Date.now() 4311 + }); 4312 + 4313 + return scriptId; 4314 + }); 4315 + 4316 + // Execute with short timeout - test executor directly 4317 + const executeResult = await sharedBgWindow.evaluate(async (scriptId) => { 4318 + const api = (window as any).app; 4319 + const { scriptExecutor } = await import('peek://ext/scripts/script-executor.js'); 4320 + 4321 + // Get the script from datastore 4322 + const settingsTable = await api.datastore.getTable('extension_settings'); 4323 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 4324 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 4325 + const script = scripts.find((s: any) => s.id === scriptId); 4326 + 4327 + if (!script) { 4328 + return { success: false, error: 'Script not found' }; 4329 + } 4330 + 4331 + // Execute directly with timeout 4332 + const result = await scriptExecutor.executeScript(script, { 4333 + url: 'https://example.com', 4334 + pageDOM: document, 4335 + pageWindow: window, 4336 + timeout: 100 // 100ms timeout 4337 + }); 4338 + 4339 + return { success: true, data: result }; 4340 + }, scriptId); 4341 + 4342 + expect(executeResult).toHaveProperty('success', true); 4343 + expect((executeResult as any).data.status).toBe('error'); 4344 + expect((executeResult as any).data.error).toContain('timeout'); 4345 + 4346 + // Clean up 4347 + await sharedBgWindow.evaluate(async (scriptId) => { 4348 + const api = (window as any).app; 4349 + const settingsTable = await api.datastore.getTable('extension_settings'); 4350 + const scriptsRow = settingsTable.data?.['scripts:scripts']; 4351 + const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 4352 + const filtered = scripts.filter((s: any) => s.id !== scriptId); 4353 + await api.datastore.setRow('extension_settings', 'scripts:scripts', { 4354 + extensionId: 'scripts', 4355 + key: 'scripts', 4356 + value: JSON.stringify(filtered), 4357 + updatedAt: Date.now() 4358 + }); 4359 + }, scriptId); 4360 + }); 4361 + });