experiments in a post-browser web
10
fork

Configure Feed

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

feat(pagestream): open pages in page host, card animation, webview fade-in

- Remove inline iframe browsing — click/Enter opens URL in real page host window
- Card-to-page ghost animation: card expands to page host size, page opens at
that exact position, ghost stays as backdrop until window renders
- Close animation: page host closes, ghost shrinks back to card
- Remove bottom navbar and browse container (page host has its own floating nav)
- Fix peek-card border CSS bug: both :host([bordered]) and :host(:not([bordered]))
matched, giving every card a border regardless
- Inject shadow DOM style overrides for opaque card backgrounds (no transparency
on transparent windows) with hardcoded hex colors
- Active card uses max-width: 518px instead of transform: scale() to avoid
overflow clipping of rounded corners
- Pagestream window: full screen width, 80% screen height
- Page host webview fade-in: detect page background color on dom-ready, set as
backing color on webview element, fade from opacity 0→1 to prevent white flash

+323 -581
+2 -7
app/components/peek-card.js
··· 76 76 transform var(--peek-transition-fast); 77 77 } 78 78 79 - /* Bordered (default) */ 80 - :host([bordered]) .card, 81 - :host(:not([bordered])) .card { 79 + /* Bordered — only when attribute is present and not "false" */ 80 + :host([bordered]:not([bordered="false"])) .card { 82 81 border: 1px solid var(--peek-card-border, var(--theme-border, #e0e0e0)); 83 - } 84 - 85 - :host([bordered="false"]) .card { 86 - border: none; 87 82 } 88 83 89 84 /* Elevated */
+6
app/page/index.html
··· 44 44 border-radius: 10px; 45 45 overflow: hidden; 46 46 -webkit-mask-image: -webkit-radial-gradient(white, white); 47 + opacity: 0; 48 + transition: opacity 0.3s ease; 49 + } 50 + 51 + webview.ready { 52 + opacity: 1; 47 53 } 48 54 49 55 /*
+39 -26
app/page/page.js
··· 913 913 console.error('[page] Load failed:', e.errorCode, e.errorDescription); 914 914 }); 915 915 916 - // Set default background color on webview content if the page doesn't set one 916 + // Detect page background color, set it as backing color on the webview element, 917 + // then fade the webview in. This prevents the white flash when loading pages in dark mode. 917 918 webview.addEventListener('dom-ready', async () => { 918 919 try { 919 - const detectionJs = ` 920 + const bgResult = await webview.executeJavaScript(` 920 921 (function() { 921 922 function isTransparentColor(color) { 922 923 if (!color) return true; 923 924 if (color === 'transparent') return true; 924 925 if (color === 'rgba(0, 0, 0, 0)') return true; 925 - const rgbaMatch = color.match(/rgba\\s*\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*([\\d.]+)\\s*\\)/); 926 + var rgbaMatch = color.match(/rgba\\s*\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*([\\d.]+)\\s*\\)/); 926 927 if (rgbaMatch && parseFloat(rgbaMatch[1]) === 0) return true; 927 928 return false; 928 929 } 929 930 930 - function hasBackground(el) { 931 - if (!el) return false; 932 - const style = window.getComputedStyle(el); 933 - const bgColor = style.backgroundColor; 934 - const hasColor = !isTransparentColor(bgColor); 935 - const bgImage = style.backgroundImage; 936 - const hasImage = bgImage && bgImage !== 'none'; 937 - return hasColor || hasImage; 931 + function getEffectiveBg(el) { 932 + if (!el) return null; 933 + var style = window.getComputedStyle(el); 934 + var bgColor = style.backgroundColor; 935 + if (!isTransparentColor(bgColor)) return bgColor; 936 + return null; 938 937 } 939 938 940 - const html = document.documentElement; 941 - const body = document.body; 942 - return !(hasBackground(html) || hasBackground(body)); 939 + var bodyBg = getEffectiveBg(document.body); 940 + var htmlBg = getEffectiveBg(document.documentElement); 941 + var detectedBg = bodyBg || htmlBg || null; 942 + var hasAnyBg = !!(bodyBg || htmlBg || 943 + (document.body && window.getComputedStyle(document.body).backgroundImage !== 'none') || 944 + (document.documentElement && window.getComputedStyle(document.documentElement).backgroundImage !== 'none')); 945 + 946 + return { detectedBg: detectedBg, hasAnyBg: hasAnyBg }; 943 947 })(); 944 - `; 948 + `); 945 949 946 - const needsBackground = await webview.executeJavaScript(detectionJs); 947 - 948 - if (needsBackground) { 949 - const defaultBg = '#ffffff'; 950 - await webview.executeJavaScript(` 951 - document.documentElement.style.backgroundColor = '${defaultBg}'; 952 - `); 953 - DEBUG && console.log('[page] No background detected, set default:', defaultBg); 954 - } else { 955 - DEBUG && console.log('[page] Page has background, skipping default'); 950 + if (!bgResult.hasAnyBg) { 951 + // Page has no background — set white inside the page 952 + await webview.executeJavaScript( 953 + "document.documentElement.style.backgroundColor = '#ffffff';" 954 + ); 955 + webview.style.background = '#ffffff'; 956 + DEBUG && console.log('[page] No background detected, set default white'); 957 + } else if (bgResult.detectedBg) { 958 + // Match the webview element's backing color to the page's actual bg 959 + webview.style.background = bgResult.detectedBg; 960 + DEBUG && console.log('[page] Detected background:', bgResult.detectedBg); 956 961 } 957 962 } catch (err) { 958 - console.error('[page] Failed to check/set background:', err); 963 + console.error('[page] Failed to detect background:', err); 959 964 } 965 + 966 + // Fade in the webview now that background is matched 967 + webview.classList.add('ready'); 968 + }); 969 + 970 + // Reset visibility on new navigations so fade-in works each time 971 + webview.addEventListener('did-start-loading', () => { 972 + webview.classList.remove('ready'); 960 973 }); 961 974 962 975 // --- OpenSearch discovery & page:loaded event ---
+10 -5
extensions/pagestream/background.js
··· 56 56 if (isOpeningPagestream) return; 57 57 isOpeningPagestream = true; 58 58 try { 59 - const height = 700; 60 - const width = 500; 59 + let screenW = 1440, screenH = 900; 60 + try { 61 + screenW = window.screen.availWidth || window.screen.width || 1440; 62 + screenH = window.screen.availHeight || window.screen.height || 900; 63 + } catch {} 64 + const width = screenW; 65 + const height = Math.round(screenH * 0.8); 61 66 62 67 const params = { 63 68 // IZUI role ··· 71 76 trackingSourceId: 'pagestream' 72 77 }; 73 78 74 - const window = await api.window.open(address, params); 75 - debug && console.log('[ext:pagestream] Pagestream window opened:', window); 76 - pagestreamWindowId = window?.id || null; 79 + const win = await api.window.open(address, params); 80 + debug && console.log('[ext:pagestream] Pagestream window opened:', win); 81 + pagestreamWindowId = win?.id || null; 77 82 } catch (error) { 78 83 console.error('[ext:pagestream] Failed to open pagestream window:', error); 79 84 } finally {
+27 -166
extensions/pagestream/home.css
··· 15 15 } 16 16 17 17 body { 18 - /* Fully transparent - window itself handles transparency */ 19 18 background: transparent; 20 19 color: var(--base05); 21 20 height: 100vh; ··· 24 23 overflow: hidden; 25 24 } 26 25 27 - /* Search */ 28 - .search-container { 29 - padding: 12px 16px 0 16px; 30 - flex-shrink: 0; 31 - } 32 - 33 - peek-input.search-input { 34 - display: block; 35 - width: 100%; 36 - --peek-input-bg: var(--base01); 37 - --peek-input-border: var(--base02); 38 - --peek-input-height: 36px; 39 - border-radius: 8px; 40 - } 41 - 42 - peek-input.search-input::part(input) { 43 - color: var(--base05); 44 - font-size: 13px; 45 - } 46 - 47 26 /* Stream container - vertical scroll area */ 48 27 .stream-container { 49 28 flex: 1; 50 29 overflow-y: auto; 51 - padding: 16px 16px; 30 + padding: 20px 16px; 52 31 display: flex; 53 32 flex-direction: column; 54 33 gap: 20px; 55 - scroll-behavior: smooth; 56 34 } 57 35 58 - /* Cards in the stream */ 36 + /* Cards — opaque backgrounds, no borders */ 59 37 .stream-container peek-card { 60 - --peek-card-bg: var(--base01); 61 - --peek-card-hover-bg: var(--base02); 38 + --peek-card-bg: var(--base01, #2c2c2e); 39 + --peek-card-hover-bg: var(--base01, #2c2c2e); 62 40 --peek-card-border: transparent; 63 41 --peek-card-radius: 8px; 64 42 --peek-card-padding: 10px; 65 43 --peek-card-gap: 4px; 44 + --peek-focus-ring: none; 45 + --theme-accent: transparent; 66 46 flex-shrink: 0; 67 - transition: transform 0.25s ease, box-shadow 0.25s ease, padding 0.25s ease, font-size 0.25s ease; 68 - } 69 - 70 - /* Remove focus ring / outline / hover border from interactive cards */ 71 - .stream-container peek-card::part(card) { 72 - outline: none !important; 73 - box-shadow: none; 47 + max-width: 480px; 48 + align-self: center; 49 + width: 100%; 50 + transition: max-width 0.2s ease, padding 0.2s ease; 74 51 } 75 52 76 - .stream-container peek-card:hover::part(card), 77 - .stream-container peek-card:focus::part(card), 78 - .stream-container peek-card:focus-visible::part(card), 79 - .stream-container peek-card:focus-within::part(card) { 80 - border-color: transparent !important; 81 - outline: none !important; 82 - } 83 - 84 - .stream-container peek-card[selected] { 85 - --peek-card-bg: var(--base02); 86 - --peek-card-border: transparent; 87 - } 88 - 89 - .stream-container peek-card[selected]:hover { 90 - --peek-card-bg: var(--base03); 91 - } 92 - 93 - /* Active/focused card - highlighted but no blue border/outline */ 53 + /* Active card — wider with more padding */ 94 54 .stream-container peek-card.active-card { 95 - --peek-card-bg: var(--base02); 96 - --peek-card-border: transparent; 97 - --peek-card-padding: 16px; 55 + --peek-card-bg: var(--base02, #3a3a3c); 56 + --peek-card-hover-bg: var(--base02, #3a3a3c); 57 + --peek-card-padding: 14px; 98 58 --peek-card-gap: 8px; 59 + max-width: 518px; 99 60 z-index: 10; 100 61 } 101 62 102 - .stream-container peek-card.active-card::part(card) { 103 - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3) !important; 104 - border-color: transparent !important; 105 - outline: none !important; 106 - } 107 - 108 63 .stream-container peek-card.active-card .card-title { 109 - font-size: 18px; 64 + font-size: 16px; 110 65 } 111 66 112 67 .stream-container peek-card.active-card .card-url { ··· 121 76 .stream-container peek-card.active-card .card-time, 122 77 .stream-container peek-card.active-card .card-domain { 123 78 font-size: 13px; 124 - } 125 - 126 - /* Non-active cards are slightly dimmed */ 127 - .stream-container peek-card:not(.active-card) { 128 - opacity: 0.75; 129 79 } 130 80 131 81 /* Card slotted content */ ··· 205 155 color: var(--base05); 206 156 } 207 157 208 - /* Navbar at bottom */ 209 - .navbar-container { 210 - padding: 8px 16px 12px 16px; 211 - flex-shrink: 0; 212 - } 213 - 214 - peek-navbar { 215 - --peek-navbar-bg: var(--base01); 216 - border-radius: 10px; 217 - } 218 - 219 158 /* Empty state */ 220 159 .empty-state { 221 160 text-align: center; ··· 228 167 justify-content: center; 229 168 } 230 169 231 - /* Loading state */ 232 - .loading-state { 233 - text-align: center; 234 - padding: 24px; 235 - color: var(--base03); 236 - font-size: 13px; 237 - } 170 + /* ===== Animation Ghost — card-to-fullscreen transition ===== */ 238 171 239 - /* Active tag filter indicator */ 240 - .filter-bar { 241 - display: none; 242 - padding: 4px 16px; 243 - flex-shrink: 0; 244 - align-items: center; 245 - gap: 8px; 246 - } 247 - 248 - .filter-bar.active { 249 - display: flex; 250 - } 251 - 252 - .filter-label { 253 - font-size: 12px; 254 - color: var(--base04); 255 - } 256 - 257 - .filter-tag { 258 - font-size: 12px; 259 - color: var(--base05); 260 - background: var(--base02); 261 - padding: 2px 8px; 262 - border-radius: 4px; 263 - } 264 - 265 - .filter-clear { 266 - font-size: 11px; 267 - color: var(--base04); 268 - cursor: pointer; 269 - margin-left: auto; 270 - } 271 - 272 - .filter-clear:hover { 273 - color: var(--base05); 274 - } 275 - 276 - /* ===== Inline Browse Mode ===== */ 277 - 278 - /* Overlay backdrop when a card is expanded */ 279 - .browse-backdrop { 172 + .anim-ghost { 280 173 position: fixed; 281 - inset: 0; 282 - background: rgba(0, 0, 0, 0.5); 283 - z-index: 99; 284 - opacity: 0; 285 - transition: opacity 0.3s ease; 174 + z-index: 300; 175 + background: #3a3a3c; 176 + border-radius: 8px; 286 177 pointer-events: none; 287 - } 288 - 289 - .browse-backdrop.active { 290 - opacity: 1; 291 - pointer-events: auto; 178 + opacity: 0; 179 + top: 0; 180 + left: 0; 181 + width: 0; 182 + height: 0; 292 183 } 293 184 294 - /* Expanded card fills the viewport */ 295 - .stream-container peek-card.card-expanded { 296 - position: fixed; 297 - top: 8px; 298 - left: 8px; 299 - right: 8px; 300 - bottom: 60px; /* leave room for navbar */ 301 - z-index: 100; 185 + .anim-ghost.visible { 302 186 opacity: 1; 303 - --peek-card-bg: var(--base01); 304 - --peek-card-padding: 0px; 305 - --peek-card-gap: 0px; 306 - --peek-card-radius: 12px; 307 - overflow: hidden; 308 - } 309 - 310 - .stream-container peek-card.card-expanded::part(card) { 311 - height: 100%; 312 - display: flex; 313 - flex-direction: column; 314 - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5) !important; 315 - border-color: transparent !important; 316 - outline: none !important; 317 - } 318 - 319 - /* iframe inside expanded card */ 320 - .browse-iframe { 321 - width: 100%; 322 - flex: 1; 323 - border: none; 324 - background: white; 325 - border-radius: 0 0 12px 12px; 326 187 }
+2 -14
extensions/pagestream/home.html
··· 26 26 <!-- Import peek-components --> 27 27 <script type="module"> 28 28 import 'peek://app/components/peek-card.js'; 29 - import 'peek://app/components/peek-input.js'; 30 - import 'peek://app/components/peek-navbar.js'; 31 29 </script> 32 30 </head> 33 31 <body> 34 - <div class="browse-backdrop" id="browse-backdrop"></div> 35 - <div class="search-container"> 36 - <peek-input 37 - class="search-input" 38 - placeholder="Search history..." 39 - type="search" 40 - ></peek-input> 41 - </div> 42 - 43 32 <div class="stream-container" id="stream"> 44 33 <div class="empty-state" id="empty-state"> 45 34 No browsing history yet. Open a page to start your stream. 46 35 </div> 47 36 </div> 48 37 49 - <div class="navbar-container"> 50 - <peek-navbar id="navbar"></peek-navbar> 51 - </div> 38 + <!-- Animation ghost — fixed positioned, card-to-fullscreen transition --> 39 + <div id="anim-ghost" class="anim-ghost"></div> 52 40 53 41 <script type="module" src="home.js"></script> 54 42 </body>
+237 -363
extensions/pagestream/home.js
··· 2 2 * Pagestream - Vertical, chat-like navigational interface for web history 3 3 * 4 4 * Displays a vertical stream of page cards from visit history. 5 - * Newest at bottom (chat-like), keyboard navigable, with URL input. 5 + * Newest at bottom (chat-like), keyboard navigable. 6 + * Click/Enter opens the URL in a page host window. 6 7 */ 7 8 8 9 const api = window.app; 9 10 const debug = api.debug; 10 11 11 - /** 12 - * Simple debounce helper 13 - */ 14 12 const debounce = (fn, ms) => { 15 13 let timer; 16 14 return (...args) => { ··· 19 17 }; 20 18 }; 21 19 22 - /** 23 - * Format a timestamp as relative time ("2m ago", "1h ago", "yesterday") 24 - */ 25 20 const formatRelativeTime = (timestamp) => { 26 21 if (!timestamp) return ''; 27 22 const now = Date.now(); ··· 39 34 return new Date(timestamp).toLocaleDateString(); 40 35 }; 41 36 42 - /** 43 - * Extract domain from a URL 44 - */ 45 37 const extractDomain = (url) => { 46 38 try { 47 39 return new URL(url).hostname; ··· 50 42 } 51 43 }; 52 44 53 - /** 54 - * Check if a string is a navigable web URL 55 - */ 56 45 const isWebUrl = (url) => { 57 46 if (!url) return false; 58 47 return url.startsWith('http://') || url.startsWith('https://'); 59 48 }; 60 49 61 - /** 62 - * Attempt to parse a string as a valid URL, adding https:// if needed 63 - */ 64 - const getValidURL = (input) => { 65 - if (!input) return null; 66 - let url = input.trim(); 50 + // ===== State ===== 67 51 68 - // Already a valid URL 69 - try { 70 - const parsed = new URL(url); 71 - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return url; 72 - } catch { 73 - // Not a valid URL yet 74 - } 75 - 76 - // Try adding https:// 77 - if (!url.includes('://')) { 78 - try { 79 - const withScheme = 'https://' + url; 80 - new URL(withScheme); 81 - return withScheme; 82 - } catch { 83 - return null; 84 - } 85 - } 86 - 87 - return null; 88 - }; 89 - 90 - // ===== State ===== 52 + const ANIM_DURATION = '0.5s'; 53 + const ANIM_EASE = 'cubic-bezier(0.4, 0, 0.2, 1)'; 91 54 92 55 let state = { 93 - visits: [], // Array of { visit, item } objects 94 - items: new Map(), // itemId -> item data 56 + visits: [], 57 + items: new Map(), 95 58 selectedIndex: -1, 96 - searchQuery: '', 97 59 filterTagId: null, 98 60 filterTagName: null, 99 61 isLoading: true, 100 - wasAtBottom: true, // Track if user was scrolled to bottom 101 - browsing: false, // Whether we're in inline browse mode 102 - browseUrl: null, // URL currently being browsed inline 103 - browseCardIndex: -1, // Index of the card being browsed 104 - browseOriginalContent: null // Saved original card innerHTML 62 + wasAtBottom: true, 63 + animating: false, 64 + openWindowId: null, 65 + openCardIndex: -1, 105 66 }; 106 67 107 - // Expose state for debugging in tests 108 68 window._pagestreamState = state; 109 69 110 70 // ===== Data Loading ===== 111 71 112 - /** 113 - * Load recent visits and their associated items 114 - */ 115 72 const loadVisits = async () => { 116 73 state.isLoading = true; 117 74 118 75 try { 119 - // Get recent item visits 120 76 const visitsResult = await api.datastore.queryItemVisits({ limit: 100 }); 121 77 if (!visitsResult.success) { 122 78 console.error('[pagestream] Failed to load visits:', visitsResult.error); ··· 126 82 127 83 const visits = visitsResult.data || []; 128 84 129 - // Get all URL items for lookup 130 85 const itemsResult = await api.datastore.queryItems({ type: 'url' }); 131 86 if (itemsResult.success && itemsResult.data) { 132 87 state.items.clear(); ··· 135 90 } 136 91 } 137 92 138 - // Build visit+item pairs, filtering to only web URLs 139 93 state.visits = visits 140 94 .map(visit => { 141 95 const item = state.items.get(visit.itemId); ··· 143 97 return { visit, item }; 144 98 }) 145 99 .filter(Boolean) 146 - // Reverse so oldest is at top, newest at bottom (chat-like) 147 100 .reverse(); 148 101 149 102 debug && console.log('[pagestream] Loaded', state.visits.length, 'visits'); ··· 155 108 } 156 109 }; 157 110 158 - /** 159 - * Load tags for a specific item 160 - */ 161 111 const loadItemTags = async (itemId) => { 162 112 try { 163 113 const result = await api.datastore.getItemTags(itemId); ··· 170 120 171 121 // ===== Rendering ===== 172 122 173 - /** 174 - * Get filtered visits based on search query and tag filter 175 - */ 176 123 const getFilteredVisits = () => { 177 124 let filtered = state.visits; 178 125 179 - // Deduplicate consecutive entries with the same URL 180 126 filtered = filtered.filter(({ item }, i, arr) => { 181 127 if (i === 0) return true; 182 128 return item.content !== arr[i - 1].item.content; 183 129 }); 184 130 185 - // Text search filter 186 - if (state.searchQuery) { 187 - const q = state.searchQuery.toLowerCase(); 188 - filtered = filtered.filter(({ item }) => { 189 - const title = item.title || ''; 190 - const url = item.content || ''; 191 - const domain = item.domain || extractDomain(url); 192 - return title.toLowerCase().includes(q) || 193 - url.toLowerCase().includes(q) || 194 - domain.toLowerCase().includes(q); 195 - }); 196 - } 197 - 198 131 return filtered; 199 132 }; 200 133 201 - /** 202 - * Get all card elements in the stream 203 - */ 204 134 const getCards = () => { 205 135 return Array.from(document.querySelectorAll('#stream peek-card')); 206 136 }; 207 137 208 - /** 209 - * Update visual selection on cards 210 - */ 211 138 const updateSelection = () => { 212 139 const cards = getCards(); 213 140 cards.forEach((card, i) => { 214 141 const isActive = (i === state.selectedIndex); 215 - // Do not set selected/elevated - those trigger blue border in peek-card shadow DOM 216 142 card.selected = false; 217 143 card.elevated = false; 218 144 if (isActive) { 219 145 card.classList.add('active-card'); 146 + const override = card.shadowRoot?.querySelector('.pagestream-override'); 147 + if (override) { 148 + override.textContent = ` 149 + .card, .card:hover, .card:active, .card:focus-visible { 150 + background: #3a3a3c !important; 151 + border: none !important; 152 + border-radius: 8px !important; 153 + outline: none !important; 154 + box-shadow: none !important; 155 + overflow: hidden !important; 156 + } 157 + `; 158 + } 220 159 } else { 221 160 card.classList.remove('active-card'); 161 + const override = card.shadowRoot?.querySelector('.pagestream-override'); 162 + if (override) { 163 + override.textContent = ` 164 + .card, .card:hover, .card:active, .card:focus-visible { 165 + background: #2c2c2e !important; 166 + border: none !important; 167 + border-radius: 8px !important; 168 + outline: none !important; 169 + box-shadow: none !important; 170 + overflow: hidden !important; 171 + } 172 + `; 173 + } 222 174 } 223 175 }); 224 176 225 - // Scroll selected card into view 226 177 const selected = cards[state.selectedIndex]; 227 178 if (selected) { 228 179 selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 229 180 } 230 181 }; 231 182 232 - /** 233 - * Render the stream of cards 234 - */ 235 183 const render = () => { 236 184 const container = document.getElementById('stream'); 237 185 const emptyState = document.getElementById('empty-state'); 238 186 const filtered = getFilteredVisits(); 239 187 240 - // Clear existing cards (but not the empty state element) 241 188 const existingCards = container.querySelectorAll('peek-card'); 242 189 existingCards.forEach(card => card.remove()); 243 190 ··· 248 195 } 249 196 250 197 if (filtered.length === 0) { 251 - if (state.searchQuery) { 252 - emptyState.textContent = 'No pages match your search.'; 253 - } else { 254 - emptyState.textContent = 'No browsing history yet. Open a page to start your stream.'; 255 - } 198 + emptyState.textContent = 'No browsing history yet. Open a page to start your stream.'; 256 199 emptyState.style.display = 'flex'; 257 200 return; 258 201 } 259 202 260 203 emptyState.style.display = 'none'; 261 204 262 - // Render cards (oldest first, newest at bottom) 263 205 filtered.forEach(({ visit, item }, index) => { 264 206 const card = createVisitCard(visit, item, index); 265 207 container.appendChild(card); 266 208 }); 267 209 268 - // Set selection to newest (bottom) if no selection 269 210 if (state.selectedIndex < 0 || state.selectedIndex >= filtered.length) { 270 211 state.selectedIndex = filtered.length - 1; 271 212 } 272 213 updateSelection(); 273 214 274 - // Scroll to bottom if we were already there 275 215 if (state.wasAtBottom) { 276 216 scrollToBottom(); 277 217 } 278 218 }; 279 219 280 - /** 281 - * Scroll the stream to the bottom 282 - */ 283 220 const scrollToBottom = () => { 284 221 const container = document.getElementById('stream'); 285 222 container.scrollTop = container.scrollHeight; 286 223 }; 287 224 288 - /** 289 - * Check if the stream is scrolled to the bottom 290 - */ 291 225 const isAtBottom = () => { 292 226 const container = document.getElementById('stream'); 293 227 const threshold = 50; 294 228 return container.scrollHeight - container.scrollTop - container.clientHeight < threshold; 295 229 }; 296 230 231 + // ===== Open URL in Page Host (with animation) ===== 232 + 233 + // Default page host size — capped so it doesn't go full screen 234 + const PAGE_HOST_WIDTH = 800; 235 + const PAGE_HOST_HEIGHT = 600; 236 + 297 237 /** 298 - * Create a card element for a visit 238 + * @param {object} from - Start rect {top, left, width, height, borderRadius} 239 + * @param {object} to - End rect 240 + * @param {boolean} keepVisible - If true, don't hide ghost after animation (caller hides it) 299 241 */ 242 + const animateGhost = (from, to, keepVisible = false) => { 243 + return new Promise((resolve) => { 244 + const ghost = document.getElementById('anim-ghost'); 245 + 246 + // Position at start (no transition) 247 + ghost.style.transition = 'none'; 248 + ghost.classList.remove('visible'); 249 + ghost.style.top = from.top + 'px'; 250 + ghost.style.left = from.left + 'px'; 251 + ghost.style.width = from.width + 'px'; 252 + ghost.style.height = from.height + 'px'; 253 + ghost.style.borderRadius = from.borderRadius || '8px'; 254 + ghost.offsetHeight; // force reflow 255 + 256 + ghost.classList.add('visible'); 257 + 258 + // Next frame — animate to target 259 + requestAnimationFrame(() => { 260 + const t = `${ANIM_DURATION} ${ANIM_EASE}`; 261 + ghost.style.transition = `top ${t}, left ${t}, width ${t}, height ${t}, border-radius ${t}`; 262 + 263 + requestAnimationFrame(() => { 264 + ghost.style.top = to.top + 'px'; 265 + ghost.style.left = to.left + 'px'; 266 + ghost.style.width = to.width + 'px'; 267 + ghost.style.height = to.height + 'px'; 268 + ghost.style.borderRadius = to.borderRadius || '0px'; 269 + 270 + const onDone = (e) => { 271 + if (e.target !== ghost || e.propertyName !== 'width') return; 272 + ghost.removeEventListener('transitionend', onDone); 273 + ghost.style.transition = ''; 274 + if (!keepVisible) { 275 + ghost.classList.remove('visible'); 276 + } 277 + resolve(); 278 + }; 279 + ghost.addEventListener('transitionend', onDone); 280 + }); 281 + }); 282 + }); 283 + }; 284 + 285 + const hideGhost = () => { 286 + const ghost = document.getElementById('anim-ghost'); 287 + ghost.classList.remove('visible'); 288 + }; 289 + 290 + /** 291 + * Convert card's viewport-relative rect to a position within the pagestream 292 + * window, then compute what that would look like as a target for the ghost 293 + * that ends at the exact screen position of the page host window. 294 + * 295 + * Ghost coordinates are relative to the pagestream window viewport. 296 + * Page host opens at absolute screen coordinates. 297 + * We need: ghost target = pageHostScreen - pagestreamScreen 298 + */ 299 + const openInPageHost = async (url) => { 300 + if (state.animating) return; 301 + state.animating = true; 302 + 303 + const cards = getCards(); 304 + const card = cards[state.selectedIndex]; 305 + if (!card) { state.animating = false; return; } 306 + 307 + state.openCardIndex = state.selectedIndex; 308 + 309 + // Card position (viewport-relative, which is pagestream-window-relative) 310 + const cardRect = card.getBoundingClientRect(); 311 + 312 + // Get pagestream window's screen position 313 + let psBounds; 314 + try { 315 + psBounds = await api.window.getBounds(); 316 + } catch { 317 + psBounds = { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; 318 + } 319 + 320 + // Center page host on the pagestream window, capped at default page size 321 + const pageHostW = PAGE_HOST_WIDTH; 322 + const pageHostH = PAGE_HOST_HEIGHT; 323 + const pageHostX = Math.round(psBounds.x + (psBounds.width - pageHostW) / 2); 324 + const pageHostY = Math.round(psBounds.y + (psBounds.height - pageHostH) / 2); 325 + 326 + // Ghost target in viewport coords (centered within pagestream window) 327 + const ghostLeft = (psBounds.width - pageHostW) / 2; 328 + const ghostTop = (psBounds.height - pageHostH) / 2; 329 + 330 + // Ghost animates from card to page host size (centered in pagestream) 331 + await animateGhost( 332 + { top: cardRect.top, left: cardRect.left, width: cardRect.width, height: cardRect.height, borderRadius: '8px' }, 333 + { top: ghostTop, left: ghostLeft, width: pageHostW, height: pageHostH, borderRadius: '10px' }, 334 + true // keepVisible — ghost stays as backdrop until window renders 335 + ); 336 + 337 + // Open the page host window at exact position ghost landed 338 + try { 339 + const result = await api.window.open(url, { 340 + role: 'content', 341 + key: url, 342 + width: pageHostW, 343 + height: pageHostH, 344 + x: pageHostX, 345 + y: pageHostY, 346 + trackingSource: 'pagestream', 347 + }); 348 + if (result && result.id) { 349 + state.openWindowId = result.id; 350 + } 351 + } catch (err) { 352 + console.error('[pagestream] Failed to open page:', err); 353 + state.openCardIndex = -1; 354 + } 355 + 356 + // Give the window time to render on top of the ghost, then hide ghost 357 + setTimeout(() => { 358 + hideGhost(); 359 + state.animating = false; 360 + }, 300); 361 + }; 362 + 363 + const onPageHostClosed = async (closedWindowId) => { 364 + if (closedWindowId !== state.openWindowId) return; 365 + state.openWindowId = null; 366 + 367 + const cards = getCards(); 368 + const card = cards[state.openCardIndex]; 369 + if (!card || state.animating) return; 370 + 371 + state.animating = true; 372 + 373 + // Get fresh card position and pagestream window size 374 + const cardRect = card.getBoundingClientRect(); 375 + const ghostLeft = (window.innerWidth - PAGE_HOST_WIDTH) / 2; 376 + const ghostTop = (window.innerHeight - PAGE_HOST_HEIGHT) / 2; 377 + 378 + // Animate from page host size (centered) → card 379 + await animateGhost( 380 + { top: ghostTop, left: ghostLeft, width: PAGE_HOST_WIDTH, height: PAGE_HOST_HEIGHT, borderRadius: '10px' }, 381 + { top: cardRect.top, left: cardRect.left, width: cardRect.width, height: cardRect.height, borderRadius: '8px' } 382 + ); 383 + 384 + state.animating = false; 385 + state.openCardIndex = -1; 386 + state.pageHostBounds = null; 387 + }; 388 + 300 389 const createVisitCard = (visit, item, index) => { 301 390 const card = document.createElement('peek-card'); 302 391 card.className = 'visit-card'; 303 392 card.interactive = true; 393 + card.bordered = false; 304 394 card.dataset.visitId = visit.id; 305 395 card.dataset.itemId = item.id; 306 396 card.dataset.index = index; 307 397 398 + // Inject <style> into shadow DOM — permanent override for opaque bg and no borders 399 + card.updateComplete.then(() => { 400 + if (card.shadowRoot && !card.shadowRoot.querySelector('.pagestream-override')) { 401 + const style = document.createElement('style'); 402 + style.className = 'pagestream-override'; 403 + style.textContent = ` 404 + .card, .card:hover, .card:active, .card:focus-visible { 405 + background: #2c2c2e !important; 406 + border: none !important; 407 + border-radius: 8px !important; 408 + outline: none !important; 409 + box-shadow: none !important; 410 + overflow: hidden !important; 411 + } 412 + `; 413 + card.shadowRoot.appendChild(style); 414 + } 415 + }); 416 + 308 417 const url = item.content; 309 418 310 - // Parse title from metadata or item fields 311 419 let displayTitle = item.title; 312 420 if (!displayTitle && item.metadata) { 313 421 try { 314 422 const meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : item.metadata; 315 423 displayTitle = meta.title; 316 424 } catch { 317 - // Ignore parse errors 425 + // Ignore 318 426 } 319 427 } 320 428 displayTitle = displayTitle || extractDomain(url); 321 429 322 430 const domain = item.domain || extractDomain(url); 323 431 324 - // Header slot: favicon + title 325 432 const header = document.createElement('div'); 326 433 header.slot = 'header'; 327 434 header.className = 'card-header'; ··· 341 448 header.appendChild(title); 342 449 card.appendChild(header); 343 450 344 - // Body: URL 345 451 const body = document.createElement('div'); 346 452 body.className = 'card-url'; 347 453 body.textContent = url; 348 454 card.appendChild(body); 349 455 350 - // Footer slot: time + domain badge 351 456 const footer = document.createElement('div'); 352 457 footer.slot = 'footer'; 353 458 footer.className = 'card-footer'; ··· 364 469 footer.appendChild(domainBadge); 365 470 card.appendChild(footer); 366 471 367 - // Load and render tags asynchronously 368 472 loadItemTags(item.id).then(tags => { 369 473 if (tags.length > 0) { 370 474 const tagsContainer = document.createElement('div'); ··· 380 484 }); 381 485 tagsContainer.appendChild(chip); 382 486 }); 383 - // Insert tags before footer 384 487 card.appendChild(tagsContainer); 385 488 } 386 489 }); 387 490 388 - // Click to open the URL inline 389 491 card.addEventListener('card-click', () => { 390 492 state.selectedIndex = index; 391 493 updateSelection(); 392 - openInline(url); 494 + openInPageHost(url); 393 495 }); 394 496 395 497 return card; 396 498 }; 397 499 398 - // ===== Inline Browsing ===== 399 - 400 - /** 401 - * Open a URL inline by expanding the active card and loading an iframe. 402 - * Extension windows don't support <webview> tags, so we use iframes. 403 - */ 404 - const openInline = (url) => { 405 - if (state.browsing) return; 406 - 407 - const cards = getCards(); 408 - const card = cards[state.selectedIndex]; 409 - if (!card) return; 410 - 411 - debug && console.log('[pagestream] Opening inline:', url); 412 - 413 - state.browsing = true; 414 - state.browseUrl = url; 415 - state.browseCardIndex = state.selectedIndex; 416 - // Save original card content so we can restore on close 417 - state.browseOriginalContent = card.innerHTML; 418 - 419 - // Clear card content and insert iframe 420 - card.innerHTML = ''; 421 - 422 - const iframe = document.createElement('iframe'); 423 - iframe.className = 'browse-iframe'; 424 - iframe.src = url; 425 - iframe.sandbox = 'allow-scripts allow-same-origin allow-forms allow-popups'; 426 - card.appendChild(iframe); 427 - 428 - // Add expanded class to trigger CSS transition 429 - card.classList.add('card-expanded'); 430 - 431 - // Show backdrop 432 - const backdrop = document.getElementById('browse-backdrop'); 433 - backdrop.classList.add('active'); 434 - 435 - // Update navbar to show the current URL 436 - const navbarEl = document.getElementById('navbar'); 437 - if (navbarEl && navbarEl.setUrl) { 438 - navbarEl.setUrl(url); 439 - } 440 - }; 441 - 442 - /** 443 - * Close browse mode: remove iframe, animate card back down, restore content 444 - */ 445 - const closeBrowse = () => { 446 - if (!state.browsing) return; 447 - 448 - const cards = getCards(); 449 - const card = cards[state.browseCardIndex]; 450 - 451 - if (card) { 452 - // Remove expanded class (triggers reverse CSS transition) 453 - card.classList.remove('card-expanded'); 454 - 455 - // Restore original card content 456 - if (state.browseOriginalContent) { 457 - card.innerHTML = state.browseOriginalContent; 458 - } 459 - } 460 - 461 - // Hide backdrop 462 - const backdrop = document.getElementById('browse-backdrop'); 463 - backdrop.classList.remove('active'); 464 - 465 - // Clear navbar URL 466 - const navbarEl = document.getElementById('navbar'); 467 - if (navbarEl && navbarEl.setUrl) { 468 - navbarEl.setUrl(''); 469 - } 470 - 471 - state.browsing = false; 472 - state.browseUrl = null; 473 - state.browseCardIndex = -1; 474 - state.browseOriginalContent = null; 475 - 476 - // Re-apply selection styling 477 - updateSelection(); 478 - }; 479 - 480 500 // ===== Filtering ===== 481 501 482 - /** 483 - * Filter stream by tag 484 - */ 485 502 const filterByTag = async (tagId, tagName) => { 486 503 state.filterTagId = tagId; 487 504 state.filterTagName = tagName; 488 505 489 - // Get items with this tag 490 506 const result = await api.datastore.getItemsByTag(tagId); 491 507 if (result.success && result.data) { 492 508 const taggedItemIds = new Set(result.data.map(item => item.id)); 493 - 494 - // Filter visits to those with tagged items 495 - const allVisits = state.visits; 496 - state.visits = allVisits.filter(({ item }) => taggedItemIds.has(item.id)); 497 - } 498 - 499 - // Show filter bar 500 - const filterBar = document.querySelector('.filter-bar'); 501 - if (filterBar) { 502 - filterBar.classList.add('active'); 503 - const filterTag = filterBar.querySelector('.filter-tag'); 504 - if (filterTag) filterTag.textContent = tagName; 509 + state.visits = state.visits.filter(({ item }) => taggedItemIds.has(item.id)); 505 510 } 506 511 507 512 render(); 508 513 }; 509 514 510 - /** 511 - * Clear tag filter 512 - */ 513 515 const clearTagFilter = async () => { 514 516 state.filterTagId = null; 515 517 state.filterTagName = null; 516 - 517 - // Reload all visits 518 518 await loadVisits(); 519 - 520 - // Hide filter bar 521 - const filterBar = document.querySelector('.filter-bar'); 522 - if (filterBar) { 523 - filterBar.classList.remove('active'); 524 - } 525 - 526 519 render(); 527 520 }; 528 521 529 522 // ===== Keyboard Navigation ===== 530 523 531 - /** 532 - * Internal ESC handler for pagestream navigation 533 - * Returns { handled: true } if we handled it, { handled: false } to close window 534 - */ 535 524 const handleEscape = () => { 536 - debug && console.log('[pagestream:esc] handleEscape called, browsing:', state.browsing, 'search:', state.searchQuery, 'filter:', state.filterTagId); 537 - 538 - // Close inline browse overlay first 539 - if (state.browsing) { 540 - closeBrowse(); 541 - return { handled: true }; 542 - } 543 - 544 - // Clear tag filter first 545 525 if (state.filterTagId) { 546 526 clearTagFilter(); 547 527 return { handled: true }; 548 528 } 549 - 550 - // Clear search next 551 - const searchInput = document.querySelector('peek-input.search-input'); 552 - if (state.searchQuery) { 553 - state.searchQuery = ''; 554 - searchInput.value = ''; 555 - render(); 556 - return { handled: true }; 557 - } 558 - 559 - // Nothing to clear - let window close 560 529 return { handled: false }; 561 530 }; 562 531 563 - /** 564 - * Handle keyboard navigation 565 - */ 566 532 const handleKeydown = (e) => { 567 - // Don't handle card navigation while in browse mode 568 - if (state.browsing) return; 569 - 570 - const searchInput = document.querySelector('peek-input.search-input'); 571 - const navbarEl = document.getElementById('navbar'); 572 - 573 - // Check if an input is focused 574 - const isSearchFocused = document.activeElement === searchInput || 575 - (searchInput && searchInput.shadowRoot?.activeElement); 576 - const isUrlFocused = document.activeElement === navbarEl || 577 - (navbarEl && navbarEl.shadowRoot?.activeElement); 578 - const isInputFocused = isSearchFocused || isUrlFocused; 579 - 580 - // Cmd+L or / to focus navbar URL input (when not in an input) 581 - if ((e.key === 'l' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !isInputFocused)) { 582 - e.preventDefault(); 583 - navbarEl.focusUrl(); 584 - scrollToBottom(); 585 - // Also select the last card (most recent) 586 - const cards = getCards(); 587 - if (cards.length > 0) { 588 - state.selectedIndex = cards.length - 1; 589 - updateSelection(); 590 - } 591 - return; 592 - } 593 - 594 - // Cmd+F to focus search 595 - if (e.key === 'f' && (e.metaKey || e.ctrlKey)) { 596 - e.preventDefault(); 597 - searchInput.focus(); 598 - return; 599 - } 600 - 601 - // Don't intercept when typing in inputs (except navigation keys) 602 - if (isInputFocused && !['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) { 603 - return; 604 - } 605 - 606 - // Enter in navbar URL is handled by the peek-navbar component's navigate event. 607 - // Skip keyboard navigation when navbar URL input is focused. 608 - if (e.key === 'Enter' && isUrlFocused) { 609 - return; 610 - } 611 - 612 533 const cards = getCards(); 613 534 if (cards.length === 0) return; 614 535 ··· 630 551 } 631 552 break; 632 553 case 'Enter': 633 - if (!isInputFocused) { 634 - e.preventDefault(); 635 - // Open the active card's URL inline 554 + e.preventDefault(); 555 + { 636 556 const filtered = getFilteredVisits(); 637 557 const entry = filtered[state.selectedIndex]; 638 558 if (entry) { 639 - openInline(entry.item.content); 559 + openInPageHost(entry.item.content); 640 560 } 641 561 } 642 562 break; 643 563 case 'g': 644 - if (!isInputFocused) { 645 - e.preventDefault(); 646 - state.selectedIndex = 0; 647 - updateSelection(); 648 - } 564 + e.preventDefault(); 565 + state.selectedIndex = 0; 566 + updateSelection(); 649 567 break; 650 568 case 'G': 651 - if (!isInputFocused) { 652 - e.preventDefault(); 653 - state.selectedIndex = cards.length - 1; 654 - updateSelection(); 655 - } 569 + e.preventDefault(); 570 + state.selectedIndex = cards.length - 1; 571 + updateSelection(); 656 572 break; 657 573 case 'Home': 658 574 e.preventDefault(); ··· 672 588 const init = async () => { 673 589 debug && console.log('[pagestream] init'); 674 590 675 - // Register escape handler 676 591 api.escape.onEscape(handleEscape); 677 592 678 - // Set up navbar component for URL navigation 679 - const navbarEl = document.getElementById('navbar'); 680 - navbarEl.addEventListener('navigate', (e) => { 681 - const url = e.detail?.url; 682 - if (url) { 683 - debug && console.log('[pagestream] Navigating to:', url); 684 - openInline(url); 685 - } 686 - }); 687 - 688 - // Set up search input 689 - const searchInput = document.querySelector('peek-input.search-input'); 690 - searchInput.addEventListener('input', (e) => { 691 - state.searchQuery = e.target.value; 692 - render(); 693 - }); 694 - 695 - // Track scroll position 696 593 const streamContainer = document.getElementById('stream'); 697 594 streamContainer.addEventListener('scroll', () => { 698 595 state.wasAtBottom = isAtBottom(); 699 596 }); 700 597 701 - // Keyboard navigation 702 598 document.addEventListener('keydown', handleKeydown); 703 599 704 - // Load initial data 705 600 await loadVisits(); 706 601 render(); 707 602 708 - // Scroll to bottom (newest) on initial load - use rAF to ensure layout is complete 603 + scrollToBottom(); 709 604 requestAnimationFrame(() => { 710 605 scrollToBottom(); 711 606 }); 712 607 713 - // Focus stays on the card stream (not the navbar) so the most recent entry is active 714 - 715 - // Debounced refresh for reactive updates 716 608 const debouncedRefresh = debounce(async () => { 717 609 debug && console.log('[pagestream] debounced refresh triggered'); 718 610 state.wasAtBottom = isAtBottom(); ··· 720 612 render(); 721 613 }, 150); 722 614 723 - // Subscribe to events for live updates 724 - api.subscribe('item:created', (msg) => { 725 - debug && console.log('[pagestream] item:created event received:', msg); 726 - debouncedRefresh(); 615 + // Listen for page host window closing — trigger collapse animation 616 + api.subscribe('window:closed', (msg) => { 617 + const closedId = msg?.id; 618 + if (closedId) onPageHostClosed(closedId); 727 619 }, api.scopes.GLOBAL); 728 620 729 - api.subscribe('item:deleted', (msg) => { 730 - debug && console.log('[pagestream] item:deleted event received:', msg); 731 - debouncedRefresh(); 732 - }, api.scopes.GLOBAL); 621 + api.subscribe('item:created', () => debouncedRefresh(), api.scopes.GLOBAL); 622 + api.subscribe('item:deleted', () => debouncedRefresh(), api.scopes.GLOBAL); 623 + api.subscribe('tag:item-added', () => debouncedRefresh(), api.scopes.GLOBAL); 624 + api.subscribe('tag:item-removed', () => debouncedRefresh(), api.scopes.GLOBAL); 625 + api.subscribe('sync:pull-completed', () => debouncedRefresh(), api.scopes.GLOBAL); 733 626 734 - api.subscribe('tag:item-added', (msg) => { 735 - debug && console.log('[pagestream] tag:item-added event received:', msg); 736 - debouncedRefresh(); 737 - }, api.scopes.GLOBAL); 738 - 739 - api.subscribe('tag:item-removed', (msg) => { 740 - debug && console.log('[pagestream] tag:item-removed event received:', msg); 741 - debouncedRefresh(); 742 - }, api.scopes.GLOBAL); 743 - 744 - // Subscribe to sync events — sync operations bypass per-item events, 745 - // so we need this to refresh when new data arrives via sync 746 - api.subscribe('sync:pull-completed', (msg) => { 747 - debug && console.log('[pagestream] sync:pull-completed event received:', msg); 748 - debouncedRefresh(); 749 - }, api.scopes.GLOBAL); 750 - 751 - // Periodically update relative timestamps 752 627 setInterval(() => { 753 628 const timeElements = document.querySelectorAll('.card-time'); 754 629 const filtered = getFilteredVisits(); ··· 757 632 el.textContent = formatRelativeTime(filtered[i].visit.timestamp); 758 633 } 759 634 }); 760 - }, 30000); // Update every 30 seconds 635 + }, 30000); 761 636 }; 762 637 763 - // Initialize when DOM is ready 764 638 document.addEventListener('DOMContentLoaded', init);