experiments in a post-browser web
10
fork

Configure Feed

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

fix(groups): unify card styling and fix keyboard navigation

- Use shared peek-card styling for both group and address cards
- Reduce padding (12px) and max-width (300px) for compact layout
- Simplify address cards to match group card structure (header + footer only)
- Remove URL display, show visit count in footer instead
- Fix keyboard navigation focus detection for peek-input shadow DOM
- Fix selected card focus for Enter key support
- Remove manual grid/card CSS in favor of peek-grid/peek-card defaults

+170 -90
+67
backend/electron/protocol.ts
··· 30 30 // Root directory (set during init) 31 31 let rootDir: string; 32 32 33 + // Bare import map for resolving npm package imports 34 + // Maps bare specifiers (e.g., 'lit') to node_modules paths 35 + const BARE_IMPORT_MAP: Record<string, string> = { 36 + 'lit': 'node_modules/lit/index.js', 37 + 'lit/': 'node_modules/lit/', 38 + '@lit/reactive-element': 'node_modules/@lit/reactive-element/reactive-element.js', 39 + '@lit/reactive-element/': 'node_modules/@lit/reactive-element/', 40 + 'lit-html': 'node_modules/lit-html/lit-html.js', 41 + 'lit-html/': 'node_modules/lit-html/', 42 + 'lit-element': 'node_modules/lit-element/lit-element.js', 43 + 'lit-element/': 'node_modules/lit-element/' 44 + }; 45 + 46 + /** 47 + * Resolve a bare import specifier to a file path 48 + * @param specifier - Bare import like 'lit' or 'lit/decorators.js' 49 + * @returns Resolved path relative to rootDir, or null if not found 50 + */ 51 + function resolveBareImport(specifier: string): string | null { 52 + // Direct match 53 + if (BARE_IMPORT_MAP[specifier]) { 54 + return BARE_IMPORT_MAP[specifier]; 55 + } 56 + 57 + // Subpath match (e.g., 'lit/decorators.js' matches 'lit/') 58 + for (const [prefix, basePath] of Object.entries(BARE_IMPORT_MAP)) { 59 + if (prefix.endsWith('/') && specifier.startsWith(prefix)) { 60 + const subpath = specifier.slice(prefix.length); 61 + return basePath + subpath; 62 + } 63 + } 64 + 65 + return null; 66 + } 67 + 33 68 /** 34 69 * Register the peek:// scheme as privileged 35 70 * MUST be called before app.ready ··· 156 191 157 192 // trim leading slash 158 193 pathname = pathname.replace(/^\//, ''); 194 + 195 + // Handle bare imports (e.g., peek://lit or peek://app/components/lit) 196 + // When modules import 'lit', browser resolves relative to module URL 197 + // So peek://app/components/peek-input.js importing 'lit' becomes peek://app/components/lit 198 + // We need to extract just the bare specifier part 199 + const fullPath = pathname ? `${host}/${pathname}` : host; 200 + 201 + // Try direct resolution first (e.g., peek://lit) 202 + let bareSpecifier = fullPath; 203 + let resolvedPath = resolveBareImport(bareSpecifier); 204 + 205 + // If that fails, check if pathname contains a known bare import 206 + // e.g., peek://app/components/lit -> extract 'lit' 207 + if (!resolvedPath && pathname) { 208 + const pathParts = pathname.split('/'); 209 + // Check each part from the end to find a bare import 210 + for (let i = pathParts.length - 1; i >= 0; i--) { 211 + const candidateSpecifier = pathParts.slice(i).join('/'); 212 + resolvedPath = resolveBareImport(candidateSpecifier); 213 + if (resolvedPath) { 214 + bareSpecifier = candidateSpecifier; 215 + break; 216 + } 217 + } 218 + } 219 + 220 + if (resolvedPath) { 221 + DEBUG && console.log(`[protocol] Resolved bare import: ${bareSpecifier} -> ${resolvedPath}`); 222 + const absolutePath = path.resolve(rootDir, resolvedPath); 223 + const fileURL = pathToFileURL(absolutePath).toString(); 224 + return net.fetch(fileURL); 225 + } 159 226 160 227 // Handle extension content: peek://ext/{ext-id}/{path} 161 228 if (host === 'ext') {
+36 -19
extensions/groups/home.css
··· 27 27 28 28 /* Component customization for peek-input */ 29 29 peek-input.search-input { 30 + display: block; 30 31 width: 100%; 31 32 --peek-input-bg: var(--base01); 32 33 --peek-input-border: var(--base02); 33 34 --peek-input-height: 44px; 34 35 } 35 36 36 - peek-input.search-input:focus-within { 37 - --peek-input-border: var(--base0D); 37 + peek-input.search-input::part(input) { 38 + color: var(--base05); 39 + font-size: 15px; 38 40 } 39 41 40 - /* Component customization for peek-grid */ 41 - peek-grid.cards { 42 + /* Cards container - peek-grid handles layout */ 43 + .cards { 42 44 padding: 24px; 43 - --peek-grid-min-item-width: 200px; 44 - --peek-grid-gap: 12px; 45 45 } 46 46 47 - /* Component customization for peek-card */ 47 + /* Component customization for peek-card - shared across all cards */ 48 48 peek-card { 49 49 --peek-card-bg: var(--base01); 50 + --peek-card-hover-bg: var(--base02); 50 51 --peek-card-border: transparent; 51 52 --peek-card-radius: 8px; 52 - --peek-card-padding: 16px; 53 + --peek-card-padding: 12px; 54 + --peek-card-gap: 8px; 55 + max-width: 300px; 53 56 } 54 57 55 58 peek-card[selected] { 56 59 --peek-card-bg: var(--base02); 57 - --peek-card-border: var(--base0D); 60 + /* Don't set --peek-card-border here as it affects footer border-top too */ 61 + /* peek-card handles selected border styling internally */ 58 62 } 59 63 60 - /* Custom styles for card content */ 61 - .color-dot { 64 + peek-card[selected]:hover { 65 + --peek-card-bg: var(--base03); 66 + } 67 + 68 + /* Group card slotted content */ 69 + .group-card .color-dot { 62 70 width: 12px; 63 71 height: 12px; 64 72 border-radius: 50%; 65 73 flex-shrink: 0; 74 + margin-top: 4px; 66 75 } 67 76 68 - .card-favicon { 69 - width: 32px; 70 - height: 32px; 77 + /* Address card slotted content */ 78 + .address-card .card-favicon { 79 + width: 12px; 80 + height: 12px; 71 81 border-radius: 4px; 72 82 flex-shrink: 0; 73 83 background: var(--base02); 74 84 object-fit: contain; 85 + margin-top: 4px; 75 86 } 76 87 77 - .card-url { 78 - font-size: 12px; 79 - color: var(--base04); 88 + /* Shared slotted content styles */ 89 + .card-title { 90 + font-size: 15px; 91 + font-weight: 600; 92 + color: var(--base05); 80 93 white-space: nowrap; 81 94 overflow: hidden; 82 95 text-overflow: ellipsis; 83 - margin-bottom: 8px; 96 + } 97 + 98 + .card-meta { 99 + font-size: 12px; 100 + color: var(--base03); 84 101 } 85 102 86 - /* Empty state - grid-column for spanning full width in peek-grid */ 103 + /* Empty state */ 87 104 .empty-state { 88 105 grid-column: 1 / -1; 89 106 text-align: center;
+17
extensions/groups/home.html
··· 7 7 <title>Groups</title> 8 8 <link rel="stylesheet" type="text/css" href="home.css"> 9 9 10 + <!-- Import map for resolving bare module specifiers --> 11 + <script type="importmap"> 12 + { 13 + "imports": { 14 + "lit": "peek://node_modules/lit/index.js", 15 + "lit/": "peek://node_modules/lit/", 16 + "lit-html": "peek://node_modules/lit-html/lit-html.js", 17 + "lit-html/": "peek://node_modules/lit-html/", 18 + "lit-element": "peek://node_modules/lit-element/lit-element.js", 19 + "lit-element/": "peek://node_modules/lit-element/", 20 + "@lit/reactive-element": "peek://node_modules/@lit/reactive-element/reactive-element.js", 21 + "@lit/reactive-element/": "peek://node_modules/@lit/reactive-element/" 22 + } 23 + } 24 + </script> 25 + 26 + <!-- Import peek-components --> 10 27 <script type="module"> 11 28 import 'peek://app/components/peek-card.js'; 12 29 import 'peek://app/components/peek-grid.js';
+50 -71
extensions/groups/home.js
··· 69 69 const searchInput = document.querySelector('peek-input.search-input'); 70 70 if (state.searchQuery) { 71 71 state.searchQuery = ''; 72 - if (searchInput) searchInput.value = ''; 72 + 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 97 96 */ 98 97 const getCards = () => { 99 98 return Array.from(document.querySelectorAll('.cards peek-card')); ··· 101 100 102 101 /** 103 102 * Update visual selection on cards 104 - * UPDATED to use peek-card selected property 105 103 */ 106 104 const updateSelection = () => { 107 105 const cards = getCards(); 108 106 cards.forEach((card, i) => { 109 - card.selected = i === state.selectedIndex; 107 + card.selected = (i === state.selectedIndex); 110 108 }); 111 109 112 - // Scroll selected card into view 110 + // Focus and scroll selected card into view 113 111 const selected = cards[state.selectedIndex]; 114 112 if (selected) { 113 + selected.focus(); 115 114 selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 116 115 } 117 116 }; ··· 146 145 */ 147 146 const handleKeydown = (e) => { 148 147 const searchInput = document.querySelector('peek-input.search-input'); 149 - const searchInputElement = searchInput?.shadowRoot?.querySelector('input'); 148 + // Check if search input or its internal input is focused 150 149 const isSearchFocused = document.activeElement === searchInput || 151 - (searchInputElement && searchInput.matches(':focus-within')); 150 + (searchInput && searchInput.shadowRoot?.activeElement); 152 151 153 152 // Focus search with / or Cmd+F 154 153 if ((e.key === '/' || (e.key === 'f' && (e.metaKey || e.ctrlKey))) && !isSearchFocused) { 155 154 e.preventDefault(); 156 - searchInput?.focus(); 155 + searchInput.focus(); 157 156 return; 158 157 } 159 158 ··· 219 218 // Load tags from datastore 220 219 await loadTags(); 221 220 222 - // Set up search input (UPDATED for peek-input) 221 + // Set up search input 223 222 const searchInput = document.querySelector('peek-input.search-input'); 224 223 searchInput.addEventListener('input', (e) => { 225 - state.searchQuery = searchInput.value; 224 + state.searchQuery = e.target.value; 226 225 renderCurrentView(); 227 226 }); 228 227 ··· 387 386 388 387 // Update search placeholder 389 388 const searchInput = document.querySelector('peek-input.search-input'); 390 - if (searchInput) { 391 - searchInput.value = ''; 392 - searchInput.placeholder = 'Search groups...'; 393 - } 389 + searchInput.value = ''; 390 + searchInput.placeholder = 'Search groups...'; 394 391 395 392 renderGroups(); 396 393 }; ··· 400 397 401 398 /** 402 399 * Render groups cards (separate from showGroups for filtering) 403 - * UPDATED to use peek-grid 404 400 */ 405 401 const renderGroups = () => { 406 - const container = document.querySelector('peek-grid.cards'); 402 + const container = document.querySelector('.cards'); 407 403 container.innerHTML = ''; 408 404 409 405 // Build list of all groups (untagged first if it has items) ··· 423 419 const message = state.searchQuery 424 420 ? 'No groups match your search.' 425 421 : 'No groups yet. Tag some pages to create groups.'; 426 - const emptyState = document.createElement('div'); 427 - emptyState.className = 'empty-state'; 428 - emptyState.textContent = message; 429 - container.appendChild(emptyState); 422 + container.innerHTML = `<div class="empty-state">${message}</div>`; 430 423 return; 431 424 } 432 425 ··· 487 480 488 481 // Update search placeholder with group name 489 482 const searchInput = document.querySelector('peek-input.search-input'); 490 - if (searchInput) { 491 - searchInput.value = ''; 492 - searchInput.placeholder = `Search in ${tag.name}...`; 493 - } 483 + searchInput.value = ''; 484 + searchInput.placeholder = `Search in ${tag.name}...`; 494 485 495 486 renderAddresses(); 496 487 }; 497 488 498 489 /** 499 490 * Render address cards (separate from showAddresses for filtering) 500 - * UPDATED to use peek-grid 501 491 */ 502 492 const renderAddresses = () => { 503 - const container = document.querySelector('peek-grid.cards'); 493 + const container = document.querySelector('.cards'); 504 494 container.innerHTML = ''; 505 495 506 496 // Apply search filter ··· 510 500 const message = state.searchQuery 511 501 ? 'No pages match your search.' 512 502 : 'No pages in this group yet.'; 513 - const emptyState = document.createElement('div'); 514 - emptyState.className = 'empty-state'; 515 - emptyState.textContent = message; 516 - container.appendChild(emptyState); 503 + container.innerHTML = `<div class="empty-state">${message}</div>`; 517 504 return; 518 505 } 519 506 ··· 529 516 530 517 /** 531 518 * Create a card element for a group (tag) 532 - * MIGRATED to use peek-card component 533 519 */ 534 520 const createGroupCard = (tag) => { 535 521 const card = document.createElement('peek-card'); 522 + card.className = 'group-card'; 536 523 card.interactive = true; 537 524 card.elevated = true; 538 525 if (tag.isSpecial) { ··· 540 527 } 541 528 card.dataset.tagId = tag.id; 542 529 543 - // Header with color dot and name 530 + // Header slot: color dot + name 544 531 const header = document.createElement('div'); 545 532 header.slot = 'header'; 546 533 header.style.display = 'flex'; 547 534 header.style.alignItems = 'center'; 548 - header.style.gap = '12px'; 535 + header.style.gap = '8px'; 549 536 550 537 const colorDot = document.createElement('div'); 551 538 colorDot.className = 'color-dot'; 552 - colorDot.style.width = '12px'; 553 - colorDot.style.height = '12px'; 554 - colorDot.style.borderRadius = '50%'; 555 - colorDot.style.flexShrink = '0'; 556 539 colorDot.style.backgroundColor = tag.color || '#999'; 557 540 558 - const name = document.createElement('span'); 559 - name.textContent = tag.name; 541 + const title = document.createElement('h2'); 542 + title.className = 'card-title'; 543 + title.textContent = tag.name; 544 + title.style.margin = '0'; 560 545 561 546 header.appendChild(colorDot); 562 - header.appendChild(name); 547 + header.appendChild(title); 563 548 card.appendChild(header); 564 549 565 - // Footer with count 566 - const footer = document.createElement('span'); 550 + // Footer slot: count 551 + const footer = document.createElement('div'); 567 552 footer.slot = 'footer'; 553 + footer.className = 'card-meta'; 568 554 const count = tag.isSpecial ? (tag.frequency || 0) : (tag.addressCount || 0); 569 555 footer.textContent = `${count} ${count === 1 ? 'page' : 'pages'}`; 570 556 card.appendChild(footer); ··· 578 564 /** 579 565 * Create a card element for an address 580 566 * Handles both Address (uri) and Item (content) objects 581 - * MIGRATED to use peek-card component 582 567 */ 583 568 const createAddressCard = (address) => { 584 569 const card = document.createElement('peek-card'); 570 + card.className = 'address-card'; 585 571 card.interactive = true; 586 572 card.elevated = true; 587 573 card.dataset.addressId = address.id; ··· 601 587 } 602 588 displayTitle = displayTitle || addressUrl; 603 589 604 - // Favicon in media slot 590 + // Header slot: favicon + title (matching group card structure) 591 + const header = document.createElement('div'); 592 + header.slot = 'header'; 593 + header.style.display = 'flex'; 594 + header.style.alignItems = 'center'; 595 + header.style.gap = '8px'; 596 + 605 597 const favicon = document.createElement('img'); 606 - favicon.slot = 'media'; 607 598 favicon.className = 'card-favicon'; 608 599 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'; 615 600 favicon.onerror = () => { 616 601 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>'; 617 602 }; 618 - card.appendChild(favicon); 619 603 620 - // Header with title 621 - const header = document.createElement('div'); 622 - header.slot = 'header'; 623 - header.textContent = displayTitle; 624 - card.appendChild(header); 604 + const title = document.createElement('h2'); 605 + title.className = 'card-title'; 606 + title.textContent = displayTitle; 607 + title.style.margin = '0'; 608 + title.style.flex = '1'; 609 + title.style.minWidth = '0'; 625 610 626 - // Body with URL 627 - const url = document.createElement('div'); 628 - url.className = 'card-url'; 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); 611 + header.appendChild(favicon); 612 + header.appendChild(title); 613 + card.appendChild(header); 636 614 637 - // Footer with metadata 638 - const footer = document.createElement('span'); 615 + // Footer slot: visit count (matching group card structure) 616 + const footer = document.createElement('div'); 639 617 footer.slot = 'footer'; 640 - const lastVisit = address.lastVisitAt ? new Date(address.lastVisitAt).toLocaleDateString() : 'Never'; 641 - footer.textContent = `${address.visitCount || 0} visits · Last: ${lastVisit}`; 618 + footer.className = 'card-meta'; 619 + const visitCount = address.visitCount || 0; 620 + footer.textContent = `${visitCount} ${visitCount === 1 ? 'visit' : 'visits'}`; 642 621 card.appendChild(footer); 643 622 644 623 // Click to open address - backend handles centering and parent tracking