experiments in a post-browser web
10
fork

Configure Feed

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

fix: prevent Chromium from caching peek:// protocol responses

fetchFile() now returns no-cache headers (Cache-Control: no-store) for
all peek:// protocol responses. Previously only theme CSS had this,
causing extension files to be served from Chromium's HTTP cache even
after app updates.

+707 -2
+14 -2
backend/electron/protocol.ts
··· 47 47 /** 48 48 * Fetch a local file, logging a warning if it doesn't exist. 49 49 * Replaces bare net.fetch(fileURL) calls so we get visibility into 404s. 50 + * Returns no-cache headers to prevent Chromium from serving stale app files. 50 51 */ 51 - function fetchFile(absolutePath: string, requestUrl: string): Response { 52 + async function fetchFile(absolutePath: string, requestUrl: string): Promise<Response> { 52 53 if (!fs.existsSync(absolutePath)) { 53 54 console.error(`[protocol] FILE NOT FOUND: ${absolutePath} (requested: ${requestUrl})`); 54 55 return new Response('Not Found', { status: 404 }); 55 56 } 56 - return net.fetch(pathToFileURL(absolutePath).toString()) as unknown as Response; 57 + const fileURL = pathToFileURL(absolutePath).toString(); 58 + const response = await net.fetch(fileURL); 59 + const body = await response.arrayBuffer(); 60 + return new Response(body, { 61 + status: response.status, 62 + headers: { 63 + 'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream', 64 + 'Cache-Control': 'no-store, no-cache, must-revalidate', 65 + 'Pragma': 'no-cache', 66 + 'Expires': '0' 67 + } 68 + }); 57 69 } 58 70 59 71 /**
+693
home.js
··· 1 + /** 2 + * Pagestream - Vertical, chat-like navigational interface for web history 3 + * 4 + * Displays a vertical stream of page cards from visit history. 5 + * Newest at bottom (chat-like), keyboard navigable. 6 + * Click/Enter opens the URL in a page host window. 7 + */ 8 + 9 + const api = window.app; 10 + const debug = api.debug; 11 + 12 + const debounce = (fn, ms) => { 13 + let timer; 14 + return (...args) => { 15 + clearTimeout(timer); 16 + timer = setTimeout(() => fn(...args), ms); 17 + }; 18 + }; 19 + 20 + const formatRelativeTime = (timestamp) => { 21 + if (!timestamp) return ''; 22 + const now = Date.now(); 23 + const diff = now - timestamp; 24 + const seconds = Math.floor(diff / 1000); 25 + const minutes = Math.floor(seconds / 60); 26 + const hours = Math.floor(minutes / 60); 27 + const days = Math.floor(hours / 24); 28 + 29 + if (seconds < 60) return 'just now'; 30 + if (minutes < 60) return `${minutes}m ago`; 31 + if (hours < 24) return `${hours}h ago`; 32 + if (days === 1) return 'yesterday'; 33 + if (days < 7) return `${days}d ago`; 34 + return new Date(timestamp).toLocaleDateString(); 35 + }; 36 + 37 + const extractDomain = (url) => { 38 + try { 39 + return new URL(url).hostname; 40 + } catch { 41 + return url; 42 + } 43 + }; 44 + 45 + const isWebUrl = (url) => { 46 + if (!url) return false; 47 + return url.startsWith('http://') || url.startsWith('https://'); 48 + }; 49 + 50 + // ===== State ===== 51 + 52 + const ANIM_DURATION = '0.2s'; 53 + const ANIM_EASE = 'cubic-bezier(0.4, 0, 0.2, 1)'; 54 + 55 + let state = { 56 + visits: [], 57 + items: new Map(), 58 + selectedIndex: -1, 59 + filterTagId: null, 60 + filterTagName: null, 61 + isLoading: true, 62 + animating: false, 63 + openWindowId: null, 64 + openCardIndex: -1, 65 + }; 66 + 67 + window._pagestreamState = state; 68 + 69 + // ===== Data Loading ===== 70 + 71 + const loadVisits = async () => { 72 + state.isLoading = true; 73 + 74 + try { 75 + const visitsResult = await api.datastore.queryItemVisits({ limit: 100 }); 76 + if (!visitsResult.success) { 77 + console.error('[pagestream] Failed to load visits:', visitsResult.error); 78 + state.visits = []; 79 + return; 80 + } 81 + 82 + const visits = visitsResult.data || []; 83 + 84 + const itemsResult = await api.datastore.queryItems({ type: 'url' }); 85 + if (itemsResult.success && itemsResult.data) { 86 + state.items.clear(); 87 + for (const item of itemsResult.data) { 88 + state.items.set(item.id, item); 89 + } 90 + } 91 + 92 + state.visits = visits 93 + .map(visit => { 94 + const item = state.items.get(visit.itemId); 95 + if (!item || !isWebUrl(item.content)) return null; 96 + return { visit, item }; 97 + }) 98 + .filter(Boolean) 99 + .reverse(); 100 + 101 + debug && console.log('[pagestream] Loaded', state.visits.length, 'visits'); 102 + } catch (err) { 103 + console.error('[pagestream] Error loading visits:', err); 104 + state.visits = []; 105 + } finally { 106 + state.isLoading = false; 107 + } 108 + }; 109 + 110 + const loadItemTags = async (itemId) => { 111 + try { 112 + const result = await api.datastore.getItemTags(itemId); 113 + if (result.success) return result.data || []; 114 + } catch { 115 + // Ignore 116 + } 117 + return []; 118 + }; 119 + 120 + // ===== Rendering ===== 121 + 122 + const getFilteredVisits = () => { 123 + let filtered = state.visits; 124 + 125 + filtered = filtered.filter(({ item }, i, arr) => { 126 + if (i === 0) return true; 127 + return item.content !== arr[i - 1].item.content; 128 + }); 129 + 130 + return filtered; 131 + }; 132 + 133 + const getCards = () => { 134 + return Array.from(document.querySelectorAll('#stream peek-card')); 135 + }; 136 + 137 + const updateSelection = (animate = true) => { 138 + const cards = getCards(); 139 + cards.forEach((card, i) => { 140 + const isActive = (i === state.selectedIndex); 141 + card.selected = false; 142 + card.elevated = false; 143 + if (isActive) { 144 + card.classList.add('active-card'); 145 + const override = card.shadowRoot?.querySelector('.pagestream-override'); 146 + if (override) { 147 + override.textContent = ` 148 + .card, .card:hover, .card:active, .card:focus-visible { 149 + background: #3a3a3c !important; 150 + border: none !important; 151 + border-radius: 8px !important; 152 + outline: none !important; 153 + box-shadow: none !important; 154 + overflow: hidden !important; 155 + } 156 + `; 157 + } 158 + } else { 159 + card.classList.remove('active-card'); 160 + const override = card.shadowRoot?.querySelector('.pagestream-override'); 161 + if (override) { 162 + override.textContent = ` 163 + .card, .card:hover, .card:active, .card:focus-visible { 164 + background: #2c2c2e !important; 165 + border: none !important; 166 + border-radius: 8px !important; 167 + outline: none !important; 168 + box-shadow: none !important; 169 + overflow: hidden !important; 170 + } 171 + `; 172 + } 173 + } 174 + }); 175 + 176 + const selected = cards[state.selectedIndex]; 177 + if (selected) { 178 + scrollCardToCenter(selected, animate); 179 + } 180 + }; 181 + 182 + /** Scroll so that the given card is vertically centered in the stream */ 183 + const scrollCardToCenter = (card, animate = true) => { 184 + const container = document.getElementById('stream'); 185 + const cardRect = card.getBoundingClientRect(); 186 + const containerRect = container.getBoundingClientRect(); 187 + 188 + // Card center relative to container's scroll position 189 + const cardCenterInContainer = (card.offsetTop + cardRect.height / 2); 190 + const targetScrollTop = cardCenterInContainer - container.clientHeight / 2; 191 + 192 + container.scrollTo({ 193 + top: targetScrollTop, 194 + behavior: animate ? 'smooth' : 'instant' 195 + }); 196 + }; 197 + 198 + const render = (initialLoad = false) => { 199 + const container = document.getElementById('stream'); 200 + const emptyState = document.getElementById('empty-state'); 201 + const filtered = getFilteredVisits(); 202 + 203 + // Remove old cards and spacers 204 + container.querySelectorAll('peek-card, .stream-spacer').forEach(el => el.remove()); 205 + 206 + if (state.isLoading) { 207 + emptyState.textContent = 'Loading history...'; 208 + emptyState.style.display = 'flex'; 209 + return; 210 + } 211 + 212 + if (filtered.length === 0) { 213 + emptyState.textContent = 'No browsing history yet. Open a page to start your stream.'; 214 + emptyState.style.display = 'flex'; 215 + return; 216 + } 217 + 218 + emptyState.style.display = 'none'; 219 + 220 + // Add top spacer — half viewport height so first card can be centered 221 + const spacerHeight = Math.floor(container.clientHeight / 2 - 60); 222 + const topSpacer = document.createElement('div'); 223 + topSpacer.className = 'stream-spacer'; 224 + topSpacer.style.height = spacerHeight + 'px'; 225 + container.appendChild(topSpacer); 226 + 227 + filtered.forEach(({ visit, item }, index) => { 228 + const card = createVisitCard(visit, item, index); 229 + container.appendChild(card); 230 + }); 231 + 232 + // Add bottom spacer 233 + const bottomSpacer = document.createElement('div'); 234 + bottomSpacer.className = 'stream-spacer'; 235 + bottomSpacer.style.height = spacerHeight + 'px'; 236 + container.appendChild(bottomSpacer); 237 + 238 + if (state.selectedIndex < 0 || state.selectedIndex >= filtered.length) { 239 + state.selectedIndex = filtered.length - 1; 240 + } 241 + // On initial load, snap to center instantly (no smooth scroll) 242 + updateSelection(!initialLoad); 243 + }; 244 + 245 + // ===== Open URL in Page Host (with animation) ===== 246 + 247 + // Default page host size — loaded from core prefs, with fallback 248 + let PAGE_HOST_WIDTH = 800; 249 + let PAGE_HOST_HEIGHT = 600; 250 + 251 + const CORE_SETTINGS_ID = '8aadaae5-2594-4968-aba0-707f0d371cfb'; 252 + 253 + const loadPageHostDefaults = async () => { 254 + try { 255 + if (api?.settings?.getExtKey) { 256 + const result = await api.settings.getExtKey(CORE_SETTINGS_ID, 'prefs'); 257 + if (result.success && result.data) { 258 + if (result.data.pageWidth) PAGE_HOST_WIDTH = result.data.pageWidth; 259 + if (result.data.pageHeight) PAGE_HOST_HEIGHT = result.data.pageHeight; 260 + debug && console.log('[pagestream] Loaded page defaults from core:', PAGE_HOST_WIDTH, 'x', PAGE_HOST_HEIGHT); 261 + } 262 + } 263 + } catch (err) { 264 + debug && console.log('[pagestream] Could not load core prefs, using defaults:', err); 265 + } 266 + }; 267 + 268 + /** 269 + * @param {object} from - Start rect {top, left, width, height, borderRadius} 270 + * @param {object} to - End rect 271 + * @param {boolean} keepVisible - If true, don't hide ghost after animation (caller hides it) 272 + */ 273 + const animateGhost = (from, to, keepVisible = false) => { 274 + return new Promise((resolve) => { 275 + const ghost = document.getElementById('anim-ghost'); 276 + 277 + // Position at start (no transition) 278 + ghost.style.transition = 'none'; 279 + ghost.classList.remove('visible'); 280 + ghost.style.top = from.top + 'px'; 281 + ghost.style.left = from.left + 'px'; 282 + ghost.style.width = from.width + 'px'; 283 + ghost.style.height = from.height + 'px'; 284 + ghost.style.borderRadius = from.borderRadius || '8px'; 285 + ghost.offsetHeight; // force reflow 286 + 287 + ghost.classList.add('visible'); 288 + 289 + // Next frame — animate to target 290 + requestAnimationFrame(() => { 291 + const t = `${ANIM_DURATION} ${ANIM_EASE}`; 292 + ghost.style.transition = `top ${t}, left ${t}, width ${t}, height ${t}, border-radius ${t}`; 293 + 294 + requestAnimationFrame(() => { 295 + ghost.style.top = to.top + 'px'; 296 + ghost.style.left = to.left + 'px'; 297 + ghost.style.width = to.width + 'px'; 298 + ghost.style.height = to.height + 'px'; 299 + ghost.style.borderRadius = to.borderRadius || '0px'; 300 + 301 + const onDone = (e) => { 302 + if (e.target !== ghost || e.propertyName !== 'width') return; 303 + ghost.removeEventListener('transitionend', onDone); 304 + ghost.style.transition = ''; 305 + if (!keepVisible) { 306 + ghost.classList.remove('visible'); 307 + } 308 + resolve(); 309 + }; 310 + ghost.addEventListener('transitionend', onDone); 311 + }); 312 + }); 313 + }); 314 + }; 315 + 316 + const hideGhost = () => { 317 + const ghost = document.getElementById('anim-ghost'); 318 + ghost.classList.remove('visible'); 319 + }; 320 + 321 + /** 322 + * Convert card's viewport-relative rect to a position within the pagestream 323 + * window, then compute what that would look like as a target for the ghost 324 + * that ends at the exact screen position of the page host window. 325 + * 326 + * Ghost coordinates are relative to the pagestream window viewport. 327 + * Page host opens at absolute screen coordinates. 328 + * We need: ghost target = pageHostScreen - pagestreamScreen 329 + */ 330 + const openInPageHost = async (url) => { 331 + if (state.animating) return; 332 + state.animating = true; 333 + 334 + const cards = getCards(); 335 + const card = cards[state.selectedIndex]; 336 + if (!card) { state.animating = false; return; } 337 + 338 + state.openCardIndex = state.selectedIndex; 339 + 340 + // Card position (viewport-relative, which is pagestream-window-relative) 341 + const cardRect = card.getBoundingClientRect(); 342 + 343 + // Get pagestream window's screen position 344 + let psBounds; 345 + try { 346 + psBounds = await api.window.getBounds(); 347 + } catch { 348 + psBounds = { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; 349 + } 350 + 351 + // Center page host on the pagestream window, capped at default page size 352 + const pageHostW = PAGE_HOST_WIDTH; 353 + const pageHostH = PAGE_HOST_HEIGHT; 354 + const pageHostX = Math.round(psBounds.x + (psBounds.width - pageHostW) / 2); 355 + const pageHostY = Math.round(psBounds.y + (psBounds.height - pageHostH) / 2); 356 + 357 + // Ghost target in viewport coords (centered within pagestream window) 358 + const ghostLeft = (psBounds.width - pageHostW) / 2; 359 + const ghostTop = (psBounds.height - pageHostH) / 2; 360 + 361 + // Ghost animates from card to page host size (centered in pagestream) 362 + await animateGhost( 363 + { top: cardRect.top, left: cardRect.left, width: cardRect.width, height: cardRect.height, borderRadius: '8px' }, 364 + { top: ghostTop, left: ghostLeft, width: pageHostW, height: pageHostH, borderRadius: '10px' }, 365 + true // keepVisible — ghost stays as backdrop until window renders 366 + ); 367 + 368 + // Open the page host window at exact position ghost landed 369 + try { 370 + const result = await api.window.open(url, { 371 + role: 'content', 372 + key: url, 373 + width: pageHostW, 374 + height: pageHostH, 375 + x: pageHostX, 376 + y: pageHostY, 377 + trackingSource: 'pagestream', 378 + }); 379 + if (result && result.id) { 380 + state.openWindowId = result.id; 381 + } 382 + } catch (err) { 383 + console.error('[pagestream] Failed to open page:', err); 384 + state.openCardIndex = -1; 385 + } 386 + 387 + // Give the window time to render on top of the ghost, then hide ghost 388 + setTimeout(() => { 389 + hideGhost(); 390 + state.animating = false; 391 + }, 300); 392 + }; 393 + 394 + // ===== Global navigation while page host is open ===== 395 + 396 + /** Move card selection and optionally navigate the open page host */ 397 + const moveSelection = (action) => { 398 + const cards = getCards(); 399 + if (cards.length === 0) return; 400 + 401 + const prevIndex = state.selectedIndex; 402 + 403 + switch (action) { 404 + case 'down': 405 + if (state.selectedIndex < cards.length - 1) state.selectedIndex++; 406 + break; 407 + case 'up': 408 + if (state.selectedIndex > 0) state.selectedIndex--; 409 + break; 410 + case 'first': 411 + state.selectedIndex = 0; 412 + break; 413 + case 'last': 414 + state.selectedIndex = cards.length - 1; 415 + break; 416 + } 417 + 418 + if (state.selectedIndex === prevIndex) return; 419 + 420 + updateSelection(); 421 + 422 + // If a page host is open, navigate it to the new card's URL 423 + if (state.openWindowId) { 424 + const filtered = getFilteredVisits(); 425 + const entry = filtered[state.selectedIndex]; 426 + if (entry) { 427 + state.openCardIndex = state.selectedIndex; 428 + api.publish('page:navigate', { 429 + windowId: state.openWindowId, 430 + url: entry.item.content 431 + }, api.scopes.GLOBAL); 432 + } 433 + } 434 + }; 435 + 436 + // Listen for navigation keys forwarded from page host via pubsub 437 + // (subscription lives for the lifetime of pagestream — moveSelection 438 + // is safe to call any time, it just no-ops if nothing is open) 439 + api.subscribe('pagestream:nav', (msg) => { 440 + if (msg.action) moveSelection(msg.action); 441 + }, api.scopes.GLOBAL); 442 + 443 + const onPageHostClosed = async (closedWindowId) => { 444 + if (closedWindowId !== state.openWindowId) return; 445 + state.openWindowId = null; 446 + 447 + const cards = getCards(); 448 + const card = cards[state.openCardIndex]; 449 + if (!card || state.animating) return; 450 + 451 + state.animating = true; 452 + 453 + // Get fresh card position and pagestream window size 454 + const cardRect = card.getBoundingClientRect(); 455 + const ghostLeft = (window.innerWidth - PAGE_HOST_WIDTH) / 2; 456 + const ghostTop = (window.innerHeight - PAGE_HOST_HEIGHT) / 2; 457 + 458 + // Animate from page host size (centered) → card 459 + await animateGhost( 460 + { top: ghostTop, left: ghostLeft, width: PAGE_HOST_WIDTH, height: PAGE_HOST_HEIGHT, borderRadius: '10px' }, 461 + { top: cardRect.top, left: cardRect.left, width: cardRect.width, height: cardRect.height, borderRadius: '8px' } 462 + ); 463 + 464 + state.animating = false; 465 + state.openCardIndex = -1; 466 + state.pageHostBounds = null; 467 + }; 468 + 469 + const createVisitCard = (visit, item, index) => { 470 + const card = document.createElement('peek-card'); 471 + card.className = 'visit-card'; 472 + card.interactive = true; 473 + card.bordered = false; 474 + card.dataset.visitId = visit.id; 475 + card.dataset.itemId = item.id; 476 + card.dataset.index = index; 477 + 478 + // Inject <style> into shadow DOM — permanent override for opaque bg and no borders 479 + card.updateComplete.then(() => { 480 + if (card.shadowRoot && !card.shadowRoot.querySelector('.pagestream-override')) { 481 + const style = document.createElement('style'); 482 + style.className = 'pagestream-override'; 483 + style.textContent = ` 484 + .card, .card:hover, .card:active, .card:focus-visible { 485 + background: #2c2c2e !important; 486 + border: none !important; 487 + border-radius: 8px !important; 488 + outline: none !important; 489 + box-shadow: none !important; 490 + overflow: hidden !important; 491 + } 492 + `; 493 + card.shadowRoot.appendChild(style); 494 + } 495 + }); 496 + 497 + const url = item.content; 498 + 499 + let displayTitle = item.title; 500 + if (!displayTitle && item.metadata) { 501 + try { 502 + const meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : item.metadata; 503 + displayTitle = meta.title; 504 + } catch { 505 + // Ignore 506 + } 507 + } 508 + displayTitle = displayTitle || extractDomain(url); 509 + 510 + const domain = item.domain || extractDomain(url); 511 + 512 + const header = document.createElement('div'); 513 + header.slot = 'header'; 514 + header.className = 'card-header'; 515 + 516 + const favicon = document.createElement('img'); 517 + favicon.className = 'card-favicon'; 518 + favicon.src = item.favicon || `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; 519 + favicon.onerror = () => { 520 + 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>'; 521 + }; 522 + 523 + const title = document.createElement('h2'); 524 + title.className = 'card-title'; 525 + title.textContent = displayTitle; 526 + 527 + header.appendChild(favicon); 528 + header.appendChild(title); 529 + card.appendChild(header); 530 + 531 + const body = document.createElement('div'); 532 + body.className = 'card-url'; 533 + body.textContent = url; 534 + card.appendChild(body); 535 + 536 + const footer = document.createElement('div'); 537 + footer.slot = 'footer'; 538 + footer.className = 'card-footer'; 539 + 540 + const time = document.createElement('span'); 541 + time.className = 'card-time'; 542 + time.textContent = formatRelativeTime(visit.timestamp); 543 + 544 + const domainBadge = document.createElement('span'); 545 + domainBadge.className = 'card-domain'; 546 + domainBadge.textContent = domain; 547 + 548 + footer.appendChild(time); 549 + footer.appendChild(domainBadge); 550 + card.appendChild(footer); 551 + 552 + loadItemTags(item.id).then(tags => { 553 + if (tags.length > 0) { 554 + const tagsContainer = document.createElement('div'); 555 + tagsContainer.className = 'card-tags'; 556 + tagsContainer.slot = 'footer'; 557 + tags.forEach(tag => { 558 + const chip = document.createElement('span'); 559 + chip.className = 'tag-chip'; 560 + chip.textContent = tag.name; 561 + chip.addEventListener('click', (e) => { 562 + e.stopPropagation(); 563 + filterByTag(tag.id, tag.name); 564 + }); 565 + tagsContainer.appendChild(chip); 566 + }); 567 + card.appendChild(tagsContainer); 568 + } 569 + }); 570 + 571 + card.addEventListener('card-click', () => { 572 + state.selectedIndex = index; 573 + updateSelection(); 574 + openInPageHost(url); 575 + }); 576 + 577 + return card; 578 + }; 579 + 580 + // ===== Filtering ===== 581 + 582 + const filterByTag = async (tagId, tagName) => { 583 + state.filterTagId = tagId; 584 + state.filterTagName = tagName; 585 + 586 + const result = await api.datastore.getItemsByTag(tagId); 587 + if (result.success && result.data) { 588 + const taggedItemIds = new Set(result.data.map(item => item.id)); 589 + state.visits = state.visits.filter(({ item }) => taggedItemIds.has(item.id)); 590 + } 591 + 592 + render(); 593 + }; 594 + 595 + const clearTagFilter = async () => { 596 + state.filterTagId = null; 597 + state.filterTagName = null; 598 + await loadVisits(); 599 + render(); 600 + }; 601 + 602 + // ===== Keyboard Navigation ===== 603 + 604 + const handleEscape = () => { 605 + if (state.filterTagId) { 606 + clearTagFilter(); 607 + return { handled: true }; 608 + } 609 + return { handled: false }; 610 + }; 611 + 612 + const handleKeydown = (e) => { 613 + const cards = getCards(); 614 + if (cards.length === 0) return; 615 + 616 + switch (e.key) { 617 + case 'j': 618 + case 'ArrowDown': 619 + e.preventDefault(); 620 + moveSelection('down'); 621 + break; 622 + case 'k': 623 + case 'ArrowUp': 624 + e.preventDefault(); 625 + moveSelection('up'); 626 + break; 627 + case 'Enter': 628 + e.preventDefault(); 629 + { 630 + const filtered = getFilteredVisits(); 631 + const entry = filtered[state.selectedIndex]; 632 + if (entry) { 633 + openInPageHost(entry.item.content); 634 + } 635 + } 636 + break; 637 + case 'g': 638 + case 'Home': 639 + e.preventDefault(); 640 + moveSelection('first'); 641 + break; 642 + case 'G': 643 + case 'End': 644 + e.preventDefault(); 645 + moveSelection('last'); 646 + break; 647 + } 648 + }; 649 + 650 + // ===== Initialization ===== 651 + 652 + const init = async () => { 653 + debug && console.log('[pagestream] init'); 654 + 655 + await loadPageHostDefaults(); 656 + 657 + api.escape.onEscape(handleEscape); 658 + 659 + document.addEventListener('keydown', handleKeydown); 660 + 661 + await loadVisits(); 662 + render(true); 663 + 664 + const debouncedRefresh = debounce(async () => { 665 + debug && console.log('[pagestream] debounced refresh triggered'); 666 + await loadVisits(); 667 + render(); 668 + }, 150); 669 + 670 + // Listen for page host window closing — trigger collapse animation 671 + api.subscribe('window:closed', (msg) => { 672 + const closedId = msg?.id; 673 + if (closedId) onPageHostClosed(closedId); 674 + }, api.scopes.GLOBAL); 675 + 676 + api.subscribe('item:created', () => debouncedRefresh(), api.scopes.GLOBAL); 677 + api.subscribe('item:deleted', () => debouncedRefresh(), api.scopes.GLOBAL); 678 + api.subscribe('tag:item-added', () => debouncedRefresh(), api.scopes.GLOBAL); 679 + api.subscribe('tag:item-removed', () => debouncedRefresh(), api.scopes.GLOBAL); 680 + api.subscribe('sync:pull-completed', () => debouncedRefresh(), api.scopes.GLOBAL); 681 + 682 + setInterval(() => { 683 + const timeElements = document.querySelectorAll('.card-time'); 684 + const filtered = getFilteredVisits(); 685 + timeElements.forEach((el, i) => { 686 + if (filtered[i]) { 687 + el.textContent = formatRelativeTime(filtered[i].visit.timestamp); 688 + } 689 + }); 690 + }, 30000); 691 + }; 692 + 693 + document.addEventListener('DOMContentLoaded', init);