experiments in a post-browser web
10
fork

Configure Feed

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

feat: restore page maximize, fill favicon gaps, space color assignment, tests

- Restore page maximize feature lost in prior overwrites: toggle, edge-to-edge
webview, floating navbar, panel clamping, drag-out restore, dblclick, IPC handler
- Favicon: add preload API wrapper, Google favicon fallback in card-helpers,
entity enrichment persists favicons
- Space colors: vivid color assignment on load, shared resolve function in config.js
- Tests: 49 favicon unit tests, 22 maximize layout tests, 9 Playwright tests unskipped

+1086 -48
+16 -2
app/lib/card-helpers.js
··· 189 189 }; 190 190 191 191 /** 192 + * Return Google favicon service URL for a given page URL, or null if domain can't be extracted. 193 + * @param {string} url 194 + * @returns {string|null} 195 + */ 196 + const _googleFaviconFallback = (url) => { 197 + if (!url) return null; 198 + try { 199 + const domain = new URL(url.startsWith('http') ? url : 'https://' + url).hostname; 200 + if (domain) return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; 201 + } catch { /* invalid URL */ } 202 + return null; 203 + }; 204 + 205 + /** 192 206 * Compute display properties (title, subtitle, faviconUrl, itemUrl) for any item. 193 207 * 194 208 * Centralizes the icon/title logic so all views (tags, search, groups) render ··· 212 226 if (isAddress) { 213 227 title = extractTitle(item, item.uri); 214 228 subtitle = formatUrl(item.uri); 215 - faviconUrl = item.favicon || opts.favicon || TYPE_ICONS.url; 229 + faviconUrl = item.favicon || opts.favicon || _googleFaviconFallback(item.uri) || TYPE_ICONS.url; 216 230 } else { 217 231 if (itemType === 'url') { 218 232 title = extractTitle(item, item.content); 219 233 subtitle = formatUrl(item.content); 220 - faviconUrl = item.favicon || opts.favicon || TYPE_ICONS.url; 234 + faviconUrl = item.favicon || opts.favicon || _googleFaviconFallback(item.content) || TYPE_ICONS.url; 221 235 } else if (itemType === 'text') { 222 236 title = item.title || (item.content || '').substring(0, 100) + ((item.content || '').length > 100 ? '...' : ''); 223 237 subtitle = noteUrl || '';
+14
app/page/index.html
··· 1205 1205 .link-status-bar.visible { 1206 1206 opacity: 1; 1207 1207 } 1208 + 1209 + /* --- Maximized mode --- */ 1210 + 1211 + body.maximized webview { 1212 + border-radius: 0; 1213 + border: none; 1214 + } 1215 + 1216 + body.maximized peek-navbar { 1217 + border-radius: 10px; 1218 + background: color-mix(in srgb, var(--base00) 85%, transparent); 1219 + backdrop-filter: blur(20px); 1220 + -webkit-backdrop-filter: blur(20px); 1221 + } 1208 1222 </style> 1209 1223 </head> 1210 1224 <body data-no-drag>
+216
app/page/page.js
··· 113 113 height: initialHeight, 114 114 }; 115 115 116 + // --- Maximize state --- 117 + let isMaximized = false; 118 + let preMaximizeBounds = null; // { x, y, width, height } screen bounds before maximize 119 + 116 120 // NOTE: The window always includes navbar space (NAVBAR_HEIGHT) to avoid 117 121 // visual jumps on show/hide. Navbar visibility is CSS-only — no window resize. 118 122 ··· 192 196 let extraWidth = 0; 193 197 194 198 function computeWindowBounds(sb) { 199 + if (isMaximized && preMaximizeBounds) { 200 + // Maximized: window fills the display work area 201 + return { 202 + x: Math.round(sb.x), 203 + y: Math.round(sb.y), 204 + width: Math.round(sb.width), 205 + height: Math.round(sb.height), 206 + }; 207 + } 195 208 const winX = sb.x - MARGIN - extraWidth / 2; 196 209 const winY = sb.y - TRIGGER_ZONE_HEIGHT - (NAVBAR_HEIGHT + NAVBAR_GAP); 197 210 const winW = sb.width + MARGIN * 2 + extraWidth; ··· 218 231 } 219 232 220 233 function updatePositions() { 234 + if (isMaximized) { 235 + updatePositionsMaximized(); 236 + return; 237 + } 221 238 // Elements are positioned within the center column, which is auto-centered 222 239 // by flexbox. When the window is wider than needed (extraWidth > 0), the 223 240 // gutters absorb the extra space — no element repositioning needed. ··· 259 276 triggerZone.style.height = navVisible ? `${TRIGGER_ZONE_HEIGHT}px` : `${navOffset + TRIGGER_ZONE_HEIGHT}px`; 260 277 261 278 // Resize handles — all four corners of the webview (24x24) 279 + resizeHandles.se.style.display = ''; 280 + resizeHandles.sw.style.display = ''; 281 + resizeHandles.ne.style.display = ''; 282 + resizeHandles.nw.style.display = ''; 283 + 262 284 resizeHandles.se.style.left = `${webviewLeft + webviewWidth - 24}px`; 263 285 resizeHandles.se.style.top = `${webviewTop + webviewHeight - 24}px`; 264 286 ··· 339 361 } 340 362 } 341 363 364 + // --- Maximized layout helpers --- 365 + 366 + function updatePositionsMaximized() { 367 + const winW = screenBounds.width; 368 + const winH = screenBounds.height; 369 + 370 + // Center column fills entire window 371 + centerColumn.style.width = `${winW}px`; 372 + 373 + // Webview fills entire window — edge to edge 374 + webview.style.left = '0px'; 375 + webview.style.top = '0px'; 376 + webview.style.width = `${winW}px`; 377 + webview.style.height = `${winH}px`; 378 + 379 + // Drag overlay matches webview 380 + dragOverlay.style.left = '0px'; 381 + dragOverlay.style.top = '0px'; 382 + dragOverlay.style.width = `${winW}px`; 383 + dragOverlay.style.height = `${winH}px`; 384 + 385 + // Navbar floats at top with margin padding 386 + const navVisible = navbar.classList.contains('visible'); 387 + navbar.style.left = `${MARGIN}px`; 388 + navbar.style.top = `${MARGIN}px`; 389 + navbar.style.width = `${winW - MARGIN * 2}px`; 390 + 391 + // Trigger zone spans full width at top 392 + triggerZone.style.left = '0px'; 393 + triggerZone.style.top = '0px'; 394 + triggerZone.style.width = `${winW}px`; 395 + triggerZone.style.height = navVisible ? `${NAVBAR_HEIGHT + MARGIN + TRIGGER_ZONE_HEIGHT}px` : `${TRIGGER_ZONE_HEIGHT + MARGIN}px`; 396 + 397 + // Resize handles hidden when maximized 398 + resizeHandles.se.style.display = 'none'; 399 + resizeHandles.sw.style.display = 'none'; 400 + resizeHandles.ne.style.display = 'none'; 401 + resizeHandles.nw.style.display = 'none'; 402 + resizeHandles.navNe.style.display = 'none'; 403 + resizeHandles.navNw.style.display = 'none'; 404 + 405 + // Position panels clamped to screen edges 406 + positionPanelsMaximized(winW, winH, navVisible); 407 + } 408 + 409 + function positionPanelsMaximized(winW, winH, navVisible) { 410 + const panelTopOffset = (navVisible ? NAVBAR_HEIGHT + MARGIN : 0) + TRIGGER_ZONE_HEIGHT + 40; 411 + 412 + // Left panels: clamped so left >= MARGIN 413 + const leftPanelLeft = MARGIN; 414 + 415 + if (pageInfoPanel) { 416 + pageInfoPanel.style.left = `${leftPanelLeft}px`; 417 + pageInfoPanel.style.top = `${panelTopOffset}px`; 418 + pageInfoPanel.style.width = `${PANEL_WIDTH}px`; 419 + } 420 + 421 + if (extensionsPanel) { 422 + extensionsPanel.style.left = `${leftPanelLeft}px`; 423 + const pageInfoHeight = (pageInfoPanel && pageInfoPanel.classList.contains('visible')) ? pageInfoPanel.offsetHeight : 0; 424 + extensionsPanel.style.top = `${panelTopOffset + (pageInfoHeight > 0 ? pageInfoHeight + 12 : 0)}px`; 425 + extensionsPanel.style.width = `${PANEL_WIDTH}px`; 426 + } 427 + 428 + // Right panels: right edge <= winW - MARGIN 429 + const rightPanelLeft = winW - MARGIN - PANEL_WIDTH; 430 + let rightStackTop = panelTopOffset; 431 + 432 + if (widgetContainer) { 433 + widgetContainer.style.left = `${rightPanelLeft}px`; 434 + widgetContainer.style.top = `${rightStackTop}px`; 435 + widgetContainer.style.width = `${PANEL_WIDTH}px`; 436 + const widgetH = (widgetContainer.classList.contains('visible') && widgetContainer.offsetHeight > 0) ? widgetContainer.offsetHeight : 0; 437 + if (widgetH > 0) rightStackTop += widgetH + 12; 438 + } 439 + 440 + if (tagsPanel) { 441 + tagsPanel.style.left = `${rightPanelLeft}px`; 442 + tagsPanel.style.top = `${rightStackTop}px`; 443 + tagsPanel.style.width = `${PANEL_WIDTH}px`; 444 + const tagsH = (tagsPanel.classList.contains('visible')) ? tagsPanel.offsetHeight : 0; 445 + if (tagsH > 0) rightStackTop += tagsH + 12; 446 + } 447 + 448 + if (notesPanel) { 449 + notesPanel.style.left = `${rightPanelLeft}px`; 450 + notesPanel.style.top = `${rightStackTop}px`; 451 + notesPanel.style.width = `${PANEL_WIDTH}px`; 452 + const notesH = (notesPanel.classList.contains('visible')) ? notesPanel.offsetHeight : 0; 453 + if (notesH > 0) rightStackTop += notesH + 12; 454 + } 455 + 456 + if (entitiesPanel) { 457 + entitiesPanel.style.left = `${rightPanelLeft}px`; 458 + entitiesPanel.style.top = `${rightStackTop}px`; 459 + entitiesPanel.style.width = `${PANEL_WIDTH}px`; 460 + } 461 + } 462 + 463 + // --- Toggle maximize --- 464 + 465 + async function toggleMaximize() { 466 + if (isMaximized) { 467 + // Restore from maximize 468 + isMaximized = false; 469 + document.body.classList.remove('maximized'); 470 + 471 + if (preMaximizeBounds) { 472 + screenBounds.x = preMaximizeBounds.x; 473 + screenBounds.y = preMaximizeBounds.y; 474 + screenBounds.width = preMaximizeBounds.width; 475 + screenBounds.height = preMaximizeBounds.height; 476 + preMaximizeBounds = null; 477 + } 478 + 479 + updatePositions(); 480 + centerColumn.style.opacity = '0'; 481 + await api.window.setBounds(computeWindowBounds(screenBounds)); 482 + centerColumn.style.opacity = '1'; 483 + updateUrlParams(); 484 + return; 485 + } 486 + 487 + // Maximize: fill screen work area 488 + const displayResult = await api.window.getDisplayInfo(); 489 + if (!displayResult || !displayResult.success || !displayResult.data) { 490 + console.warn('[page] Failed to get display info for maximize'); 491 + return; 492 + } 493 + 494 + const workArea = displayResult.data.workArea; 495 + 496 + // Save current bounds for restore 497 + preMaximizeBounds = { 498 + x: screenBounds.x, 499 + y: screenBounds.y, 500 + width: screenBounds.width, 501 + height: screenBounds.height, 502 + }; 503 + 504 + isMaximized = true; 505 + document.body.classList.add('maximized'); 506 + 507 + // screenBounds now represents the full work area (window = work area in maximized mode) 508 + screenBounds.x = workArea.x; 509 + screenBounds.y = workArea.y; 510 + screenBounds.width = workArea.width; 511 + screenBounds.height = workArea.height; 512 + 513 + updatePositions(); 514 + centerColumn.style.opacity = '0'; 515 + await api.window.setBounds(computeWindowBounds(screenBounds)); 516 + centerColumn.style.opacity = '1'; 517 + updateUrlParams(); 518 + } 519 + 342 520 // --- Set window bounds via IPC --- 343 521 344 522 let pendingBoundsUpdate = null; ··· 357 535 // Expands the window symmetrically. Flexbox keeps the center column in place. 358 536 // Returns a promise that resolves when the window bounds have been applied. 359 537 function setWindowPadding(width) { 538 + if (isMaximized) return Promise.resolve(); 360 539 width = Math.max(0, width); 361 540 if (width === extraWidth) return Promise.resolve(); 362 541 extraWidth = width; ··· 547 726 })(); 548 727 549 728 function startDrag(screenX, screenY) { 729 + // Drag-out-of-maximize: restore original size, center on cursor 730 + if (isMaximized && preMaximizeBounds) { 731 + const restoreW = preMaximizeBounds.width; 732 + const restoreH = preMaximizeBounds.height; 733 + isMaximized = false; 734 + document.body.classList.remove('maximized'); 735 + 736 + // Center the restored window on the cursor 737 + screenBounds.x = screenX - restoreW / 2; 738 + screenBounds.y = screenY; 739 + screenBounds.width = restoreW; 740 + screenBounds.height = restoreH; 741 + preMaximizeBounds = null; 742 + 743 + updatePositions(); 744 + api.window.setBounds(computeWindowBounds(screenBounds)); 745 + } 550 746 isDragging = true; 551 747 dragStartScreenX = screenX; 552 748 dragStartScreenY = screenY; ··· 1465 1661 description: 'Reload the current page', 1466 1662 scope: 'window', 1467 1663 execute: () => webview.reload() 1664 + }); 1665 + 1666 + api.commands.register({ 1667 + name: 'Maximize', 1668 + description: 'Toggle maximize for the current page', 1669 + scope: 'window', 1670 + execute: () => toggleMaximize() 1671 + }); 1672 + 1673 + // Subscribe to page:maximize pubsub 1674 + api.subscribe('page:maximize', (msg) => { 1675 + if (msg.windowId != null && msg.windowId !== myWindowId) return; 1676 + toggleMaximize(); 1677 + }, api.scopes.GLOBAL); 1678 + 1679 + // Navbar double-click to toggle maximize 1680 + navbar.addEventListener('dblclick', (e) => { 1681 + // Only on the navbar background (not on buttons/inputs inside shadow DOM) 1682 + e.preventDefault(); 1683 + toggleMaximize(); 1468 1684 }); 1469 1685 1470 1686 // --- Space mode widget ---
+7
backend/electron/ipc.ts
··· 3562 3562 } 3563 3563 }); 3564 3564 3565 + ipcMain.handle('get-display-info', (ev) => { 3566 + const win = BrowserWindow.fromWebContents(ev.sender); 3567 + if (!win || win.isDestroyed()) return { success: false, error: 'Window not found' }; 3568 + const display = screen.getDisplayMatching(win.getBounds()); 3569 + return { success: true, data: { workArea: display.workArea, bounds: display.bounds, scaleFactor: display.scaleFactor } }; 3570 + }); 3571 + 3565 3572 ipcMain.handle('window-set-bounds', (ev, msg) => { 3566 3573 const win = BrowserWindow.fromWebContents(ev.sender); 3567 3574 if (!win || win.isDestroyed()) return { success: false, error: 'Window not found' };
+5
features/entities/background.js
··· 112 112 await api.datastore.updateItemTitle(url, pageMetadata.title); 113 113 } 114 114 115 + // Persist favicon if available 116 + if (pageMetadata.favicon) { 117 + await api.datastore.updateItemFavicon(url, pageMetadata.favicon); 118 + } 119 + 115 120 // Update metadata with _og data (skip if already enriched) 116 121 const existingMeta = item.metadata ? JSON.parse(item.metadata) : {}; 117 122 if (existingMeta._og) return;
+12 -26
features/spaces/background.js
··· 9 9 10 10 import { 11 11 id, labels, schemas, storageKeys, defaults, 12 - SPACE_TAG_PREFIX, isSpaceTag, getSpaceDisplayName, toSpaceTagName 12 + SPACE_TAG_PREFIX, isSpaceTag, getSpaceDisplayName, toSpaceTagName, 13 + resolveVividColor 13 14 } from './config.js'; 14 15 15 16 const api = window.app; ··· 23 24 let activeSpaceColor = null; 24 25 25 26 // ===== Vivid Colors (for border when tag color is too pale) ===== 26 - const VIVID_SPACE_COLORS = [ 27 - '#ff3b30', '#ff9500', '#34c759', '#007aff', 28 - '#af52de', '#5ac8fa', '#ff2d55', '#ff9f0a', 29 - ]; 30 - 31 - function resolveSpaceBorderColor(color, spaceId) { 32 - if (color && color !== '#999' && color !== '#999999') { 33 - const hex = color.replace('#', ''); 34 - if (hex.length >= 6) { 35 - const r = parseInt(hex.substring(0, 2), 16); 36 - const g = parseInt(hex.substring(2, 4), 16); 37 - const b = parseInt(hex.substring(4, 6), 16); 38 - const chroma = Math.max(r, g, b) - Math.min(r, g, b); 39 - if (chroma > 40) return color; 40 - } 41 - } 42 - if (spaceId) { 43 - let hash = 0; 44 - for (let i = 0; i < spaceId.length; i++) { 45 - hash = ((hash << 5) - hash + spaceId.charCodeAt(i)) | 0; 46 - } 47 - return VIVID_SPACE_COLORS[Math.abs(hash) % VIVID_SPACE_COLORS.length]; 48 - } 49 - return '#007aff'; 50 - } 27 + // Uses shared resolveVividColor from config.js 28 + const resolveSpaceBorderColor = resolveVividColor; 51 29 52 30 // ===== Screen Border ===== 53 31 // The border is a feature-owned overlay window showing a colored border ··· 249 227 const tagResult = await api.datastore.getOrCreateTag(tagName); 250 228 if (!tagResult.success) return { success: false, error: 'Failed to get/create space tag' }; 251 229 const tag = tagResult.data.tag; 230 + 231 + // Resolve vivid color for spaces with default/grey color and persist it 232 + const vividColor = resolveVividColor(tag.color, tag.id); 233 + if (vividColor !== tag.color) { 234 + tag.color = vividColor; 235 + await api.datastore.updateTagColor(tag.id, vividColor); 236 + debug && console.log('[ext:spaces] Persisted vivid color for space:', vividColor); 237 + } 252 238 253 239 activeSpaceId = tag.id; 254 240 activeSpaceName = getSpaceDisplayName(tag);
+34
features/spaces/config.js
··· 45 45 return SPACE_TAG_PREFIX + name; 46 46 }; 47 47 48 + // Vivid colors for spaces (used for border overlay and UI dots) 49 + const VIVID_SPACE_COLORS = [ 50 + '#ff3b30', '#ff9500', '#34c759', '#007aff', 51 + '#af52de', '#5ac8fa', '#ff2d55', '#ff9f0a', 52 + ]; 53 + 54 + /** 55 + * Resolve a vivid color for a space. 56 + * If the current color is grey/desaturated (#999, low chroma), pick a vivid one 57 + * deterministically based on spaceId hash. 58 + */ 59 + const resolveVividColor = (color, spaceId) => { 60 + if (color && color !== '#999' && color !== '#999999') { 61 + const hex = color.replace('#', ''); 62 + if (hex.length >= 6) { 63 + const r = parseInt(hex.substring(0, 2), 16); 64 + const g = parseInt(hex.substring(2, 4), 16); 65 + const b = parseInt(hex.substring(4, 6), 16); 66 + const chroma = Math.max(r, g, b) - Math.min(r, g, b); 67 + if (chroma > 40) return color; 68 + } 69 + } 70 + if (spaceId) { 71 + let hash = 0; 72 + for (let i = 0; i < spaceId.length; i++) { 73 + hash = ((hash << 5) - hash + spaceId.charCodeAt(i)) | 0; 74 + } 75 + return VIVID_SPACE_COLORS[Math.abs(hash) % VIVID_SPACE_COLORS.length]; 76 + } 77 + return '#007aff'; 78 + }; 79 + 48 80 export { 49 81 id, 50 82 labels, ··· 55 87 isSpaceTag, 56 88 getSpaceDisplayName, 57 89 toSpaceTagName, 90 + VIVID_SPACE_COLORS, 91 + resolveVividColor, 58 92 };
+11
features/spaces/home.js
··· 16 16 createViewPrefs 17 17 } from 'peek://app/lib/grid-nav.js'; 18 18 import { createSearchResultCard } from 'peek://app/lib/search-result-card.js'; 19 + import { resolveVividColor } from './config.js'; 19 20 20 21 const api = window.app; 21 22 const debug = api.debug; ··· 229 230 tag.itemCount = 0; 230 231 } 231 232 } 233 + // Resolve vivid colors for spaces with default/grey color 234 + for (const tag of spaceTags) { 235 + const vivid = resolveVividColor(tag.color, tag.id); 236 + if (vivid !== tag.color) { 237 + tag.color = vivid; 238 + api.datastore.updateTagColor(tag.id, vivid); 239 + debug && console.log('[spaces] Persisted vivid color for space:', tag.name, vivid); 240 + } 241 + } 242 + 232 243 state.spaces = spaceTags; 233 244 debug && console.log('[spaces] Loaded spaces:', state.spaces.length); 234 245 } else {
+8
preload.js
··· 343 343 setBounds: (bounds) => { 344 344 return ipcRenderer.invoke('window-set-bounds', bounds); 345 345 }, 346 + getDisplayInfo: () => { 347 + return ipcRenderer.invoke('get-display-info'); 348 + }, 346 349 setIgnoreMouseEvents: (id, ignore, forward = false) => { 347 350 DEBUG && console.log('window.setIgnoreMouseEvents', id, ignore, forward); 348 351 return ipcRenderer.invoke('window-set-ignore-mouse-events', { ··· 574 577 // Update title for a URL item (if currently empty or "Loading...") 575 578 updateItemTitle: (url, title) => { 576 579 return ipcRenderer.invoke('datastore-update-item-title', { url, title }); 580 + }, 581 + 582 + // Update favicon for a URL item (3-phase lookup: item.content, address.uri, item.id) 583 + updateItemFavicon: (url, faviconUrl) => { 584 + return ipcRenderer.invoke('datastore-update-item-favicon', { url, faviconUrl }); 577 585 }, 578 586 579 587 // Extract page content from a live webContents by URL
+10 -20
tests/desktop/page-layout.spec.ts
··· 398 398 }); 399 399 400 400 // ============================================================================ 401 - // New Behavior Tests (6-14): Will pass after maximize implementation 402 - // These test maximize functionality that doesn't exist yet. 401 + // Maximize Tests (6-14): Verify maximize/restore functionality 403 402 // ============================================================================ 404 403 405 404 test.describe('Page Layout Maximize @desktop', () => { 406 405 407 406 // Test 6: Maximize command fills work area 408 - // Will pass after maximize implementation 409 - test.skip('maximize command fills work area', async () => { 407 + test('maximize command fills work area', async () => { 410 408 const { pageWindow, windowId } = await openCanvasPage( 411 409 sharedBgWindow, 412 410 'https://example.com' ··· 439 437 }); 440 438 441 439 // Test 7: Maximize toggle restores original size 442 - // Will pass after maximize implementation 443 - test.skip('maximize toggle restores original size', async () => { 440 + test('maximize toggle restores original size', async () => { 444 441 const { pageWindow, windowId } = await openCanvasPage( 445 442 sharedBgWindow, 446 443 'https://example.com' ··· 492 489 }); 493 490 494 491 // Test 8: Webview edge-to-edge when maximized (left=0, top=0) 495 - // Will pass after maximize implementation 496 - test.skip('webview fills window when maximized', async () => { 492 + test('webview fills window when maximized', async () => { 497 493 const { pageWindow, windowId } = await openCanvasPage( 498 494 sharedBgWindow, 499 495 'https://example.com' ··· 525 521 }); 526 522 527 523 // Test 9: Navbar floats over webview when maximized 528 - // Will pass after maximize implementation 529 - test.skip('navbar floats over webview when maximized', async () => { 524 + test('navbar floats over webview when maximized', async () => { 530 525 const { pageWindow, windowId } = await openCanvasPage( 531 526 sharedBgWindow, 532 527 'https://example.com' ··· 583 578 }); 584 579 585 580 // Test 10: Panels clamped to screen edge when maximized 586 - // Will pass after maximize implementation 587 - test.skip('panels clamped to screen edges when maximized', async () => { 581 + test('panels clamped to screen edges when maximized', async () => { 588 582 const { pageWindow, windowId } = await openCanvasPage( 589 583 sharedBgWindow, 590 584 'https://example.com' ··· 638 632 }); 639 633 640 634 // Test 11: Resize handles hidden when maximized 641 - // Will pass after maximize implementation 642 - test.skip('resize handles hidden when maximized', async () => { 635 + test('resize handles hidden when maximized', async () => { 643 636 const { pageWindow, windowId } = await openCanvasPage( 644 637 sharedBgWindow, 645 638 'https://example.com' ··· 676 669 }); 677 670 678 671 // Test 12: Drag out of maximized state restores 679 - // Will pass after maximize implementation 680 - test.skip('drag out of maximized state restores original size', async () => { 672 + test('drag out of maximized state restores original size', async () => { 681 673 const { pageWindow, windowId } = await openCanvasPage( 682 674 sharedBgWindow, 683 675 'https://example.com' ··· 754 746 }); 755 747 756 748 // Test 13: Webview border-radius removed when maximized 757 - // Will pass after maximize implementation 758 - test.skip('webview border-radius removed when maximized', async () => { 749 + test('webview border-radius removed when maximized', async () => { 759 750 const { pageWindow, windowId } = await openCanvasPage( 760 751 sharedBgWindow, 761 752 'https://example.com' ··· 796 787 }); 797 788 798 789 // Test 14: Double-click navbar maximizes 799 - // Will pass after maximize implementation 800 - test.skip('double-click navbar triggers maximize', async () => { 790 + test('double-click navbar triggers maximize', async () => { 801 791 const { pageWindow, windowId } = await openCanvasPage( 802 792 sharedBgWindow, 803 793 'https://example.com'
+430
tests/unit/card-helpers-favicon.test.js
··· 1 + /** 2 + * Unit tests for card-helpers.js favicon logic and getItemDisplayInfo(). 3 + * 4 + * Tests the favicon fallback chain and display info computation for all item 5 + * types. Pure functions are duplicated here since the module depends on 6 + * DOM APIs (document.createElement) which are unavailable in Node. 7 + * 8 + * Run via: node --test tests/unit/card-helpers-favicon.test.js 9 + */ 10 + import { describe, it } from 'node:test'; 11 + import { strict as assert } from 'node:assert'; 12 + 13 + // --------------------------------------------------------------------------- 14 + // Pure functions extracted from app/lib/card-helpers.js 15 + // These are duplicated here because the module uses DOM APIs. 16 + // --------------------------------------------------------------------------- 17 + 18 + const GLOBE_FAVICON = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F310}</text></svg>'; 19 + 20 + const TYPE_ICONS = { 21 + url: GLOBE_FAVICON, 22 + text: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F4DD}</text></svg>', 23 + tagset: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F3F7}\uFE0F</text></svg>', 24 + image: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F5BC}\uFE0F</text></svg>', 25 + entity: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F48E}</text></svg>', 26 + feed: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F4E1}</text></svg>', 27 + series: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F4DA}</text></svg>', 28 + unknown: '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>' 29 + }; 30 + 31 + const extractTitle = (item, fallback) => { 32 + let title = item.title; 33 + if (!title && item.metadata) { 34 + try { 35 + const meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : item.metadata; 36 + title = meta.title; 37 + } catch (e) { /* ignore */ } 38 + } 39 + return title || fallback || item.uri || item.content || '(untitled)'; 40 + }; 41 + 42 + const formatUrl = (url) => { 43 + if (!url) return ''; 44 + try { 45 + const u = new URL(url); 46 + return u.hostname + (u.pathname !== '/' ? u.pathname : ''); 47 + } catch { 48 + return url; 49 + } 50 + }; 51 + 52 + const extractUrl = (text) => { 53 + if (!text) return null; 54 + const trimmed = text.trim(); 55 + if (/^https?:\/\//i.test(trimmed)) { 56 + try { new URL(trimmed); return trimmed; } catch (e) { /* fall through */ } 57 + } 58 + if (/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/\S*)?$/.test(trimmed)) { 59 + try { new URL('https://' + trimmed); return 'https://' + trimmed; } catch (e) { /* not valid */ } 60 + } 61 + return null; 62 + }; 63 + 64 + const _googleFaviconFallback = (url) => { 65 + if (!url) return null; 66 + try { 67 + const domain = new URL(url.startsWith('http') ? url : 'https://' + url).hostname; 68 + if (domain) return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; 69 + } catch { /* invalid URL */ } 70 + return null; 71 + }; 72 + 73 + const getItemDisplayInfo = (item, opts = {}) => { 74 + const tags = opts.tags || []; 75 + const isAddress = !!item.uri; 76 + const itemType = item.type || 'url'; 77 + const noteUrl = (itemType === 'text') ? extractUrl(item.content) : null; 78 + 79 + let title, subtitle; 80 + let faviconUrl = TYPE_ICONS.unknown; 81 + 82 + if (isAddress) { 83 + title = extractTitle(item, item.uri); 84 + subtitle = formatUrl(item.uri); 85 + faviconUrl = item.favicon || opts.favicon || _googleFaviconFallback(item.uri) || TYPE_ICONS.url; 86 + } else { 87 + if (itemType === 'url') { 88 + title = extractTitle(item, item.content); 89 + subtitle = formatUrl(item.content); 90 + faviconUrl = item.favicon || opts.favicon || _googleFaviconFallback(item.content) || TYPE_ICONS.url; 91 + } else if (itemType === 'text') { 92 + title = item.title || (item.content || '').substring(0, 100) + ((item.content || '').length > 100 ? '...' : ''); 93 + subtitle = noteUrl || ''; 94 + faviconUrl = TYPE_ICONS.text; 95 + } else if (itemType === 'tagset') { 96 + title = 'Tag Set'; 97 + subtitle = tags.length > 0 ? tags.map(t => t.name).join(', ') : 'Empty tagset'; 98 + faviconUrl = TYPE_ICONS.tagset; 99 + } else if (itemType === 'image') { 100 + title = item.content || 'Image'; 101 + subtitle = 'Image'; 102 + faviconUrl = TYPE_ICONS.image; 103 + } else if (itemType === 'entity') { 104 + title = item.title || item.content || 'Entity'; 105 + let entityMeta = {}; 106 + try { entityMeta = JSON.parse(item.metadata || '{}'); } catch {} 107 + const entityKind = entityMeta.entityType ? entityMeta.entityType.replace('_', ' ') : 'entity'; 108 + subtitle = entityKind.charAt(0).toUpperCase() + entityKind.slice(1); 109 + faviconUrl = TYPE_ICONS.entity; 110 + } else if (itemType === 'feed') { 111 + title = item.title || item.content || 'Feed'; 112 + subtitle = 'Feed'; 113 + faviconUrl = TYPE_ICONS.feed; 114 + } else if (itemType === 'series') { 115 + title = item.title || item.content || 'Series'; 116 + subtitle = 'Series'; 117 + faviconUrl = TYPE_ICONS.series; 118 + } 119 + } 120 + 121 + const itemUrl = isAddress ? item.uri : (itemType === 'url') ? item.content : noteUrl; 122 + 123 + return { tags, isAddress, itemType, noteUrl, title, subtitle, faviconUrl, itemUrl }; 124 + }; 125 + 126 + // =========================================================================== 127 + // Tests: _googleFaviconFallback 128 + // =========================================================================== 129 + 130 + describe('_googleFaviconFallback', () => { 131 + it('returns Google favicon URL for a valid https URL', () => { 132 + const result = _googleFaviconFallback('https://example.com/page'); 133 + assert.equal(result, 'https://www.google.com/s2/favicons?domain=example.com&sz=32'); 134 + }); 135 + 136 + it('returns Google favicon URL for a valid http URL', () => { 137 + const result = _googleFaviconFallback('http://news.ycombinator.com'); 138 + assert.equal(result, 'https://www.google.com/s2/favicons?domain=news.ycombinator.com&sz=32'); 139 + }); 140 + 141 + it('prepends https:// when URL lacks protocol', () => { 142 + const result = _googleFaviconFallback('github.com/user/repo'); 143 + assert.equal(result, 'https://www.google.com/s2/favicons?domain=github.com&sz=32'); 144 + }); 145 + 146 + it('returns null for null input', () => { 147 + assert.equal(_googleFaviconFallback(null), null); 148 + }); 149 + 150 + it('returns null for undefined input', () => { 151 + assert.equal(_googleFaviconFallback(undefined), null); 152 + }); 153 + 154 + it('returns null for empty string', () => { 155 + assert.equal(_googleFaviconFallback(''), null); 156 + }); 157 + 158 + it('returns null for completely invalid URL', () => { 159 + // A string that cannot be parsed even with https:// prefix 160 + assert.equal(_googleFaviconFallback('://'), null); 161 + }); 162 + 163 + it('extracts domain from URL with path, query, and fragment', () => { 164 + const result = _googleFaviconFallback('https://docs.python.org/3/library/json.html?highlight=json#module-json'); 165 + assert.equal(result, 'https://www.google.com/s2/favicons?domain=docs.python.org&sz=32'); 166 + }); 167 + 168 + it('extracts domain from URL with port', () => { 169 + const result = _googleFaviconFallback('https://localhost:3000/app'); 170 + assert.equal(result, 'https://www.google.com/s2/favicons?domain=localhost&sz=32'); 171 + }); 172 + 173 + it('handles subdomain correctly', () => { 174 + const result = _googleFaviconFallback('https://blog.example.co.uk/posts'); 175 + assert.equal(result, 'https://www.google.com/s2/favicons?domain=blog.example.co.uk&sz=32'); 176 + }); 177 + }); 178 + 179 + // =========================================================================== 180 + // Tests: getItemDisplayInfo — favicon fallback chain 181 + // =========================================================================== 182 + 183 + describe('getItemDisplayInfo favicon fallback chain', () => { 184 + 185 + describe('URL items (non-address)', () => { 186 + it('uses item.favicon when present', () => { 187 + const item = { type: 'url', content: 'https://example.com', favicon: 'https://example.com/icon.png' }; 188 + const info = getItemDisplayInfo(item); 189 + assert.equal(info.faviconUrl, 'https://example.com/icon.png'); 190 + }); 191 + 192 + it('uses opts.favicon when item.favicon is missing', () => { 193 + const item = { type: 'url', content: 'https://example.com' }; 194 + const info = getItemDisplayInfo(item, { favicon: 'https://cdn.example.com/fav.png' }); 195 + assert.equal(info.faviconUrl, 'https://cdn.example.com/fav.png'); 196 + }); 197 + 198 + it('falls back to Google favicon service when no explicit favicon', () => { 199 + const item = { type: 'url', content: 'https://example.com/page' }; 200 + const info = getItemDisplayInfo(item); 201 + assert.equal(info.faviconUrl, 'https://www.google.com/s2/favicons?domain=example.com&sz=32'); 202 + }); 203 + 204 + it('falls back to TYPE_ICONS.url when URL is invalid (no domain extractable)', () => { 205 + const item = { type: 'url', content: 'not-a-valid-url' }; 206 + const info = getItemDisplayInfo(item); 207 + // 'not-a-valid-url' treated as domain-less -> _googleFaviconFallback will attempt 208 + // 'https://not-a-valid-url' which is technically parseable but has no TLD. 209 + // The key assertion: it should NOT be TYPE_ICONS.unknown 210 + assert.ok(info.faviconUrl !== TYPE_ICONS.unknown, 'should not be unknown icon'); 211 + }); 212 + 213 + it('item.favicon takes priority over opts.favicon', () => { 214 + const item = { type: 'url', content: 'https://example.com', favicon: 'https://a.com/icon.png' }; 215 + const info = getItemDisplayInfo(item, { favicon: 'https://b.com/icon.png' }); 216 + assert.equal(info.faviconUrl, 'https://a.com/icon.png'); 217 + }); 218 + }); 219 + 220 + describe('address items (has uri)', () => { 221 + it('uses item.favicon when present', () => { 222 + const item = { uri: 'https://example.com', favicon: 'https://example.com/fav.ico' }; 223 + const info = getItemDisplayInfo(item); 224 + assert.equal(info.faviconUrl, 'https://example.com/fav.ico'); 225 + }); 226 + 227 + it('uses opts.favicon as second fallback', () => { 228 + const item = { uri: 'https://example.com' }; 229 + const info = getItemDisplayInfo(item, { favicon: 'https://x.com/fav.ico' }); 230 + assert.equal(info.faviconUrl, 'https://x.com/fav.ico'); 231 + }); 232 + 233 + it('falls back to Google favicon service', () => { 234 + const item = { uri: 'https://github.com/user/repo' }; 235 + const info = getItemDisplayInfo(item); 236 + assert.equal(info.faviconUrl, 'https://www.google.com/s2/favicons?domain=github.com&sz=32'); 237 + }); 238 + 239 + it('sets isAddress true for items with uri', () => { 240 + const item = { uri: 'https://example.com' }; 241 + const info = getItemDisplayInfo(item); 242 + assert.equal(info.isAddress, true); 243 + }); 244 + }); 245 + 246 + describe('non-URL item types use type-specific icons', () => { 247 + it('text items use TYPE_ICONS.text', () => { 248 + const item = { type: 'text', content: 'some note text' }; 249 + const info = getItemDisplayInfo(item); 250 + assert.equal(info.faviconUrl, TYPE_ICONS.text); 251 + }); 252 + 253 + it('tagset items use TYPE_ICONS.tagset', () => { 254 + const item = { type: 'tagset' }; 255 + const info = getItemDisplayInfo(item); 256 + assert.equal(info.faviconUrl, TYPE_ICONS.tagset); 257 + }); 258 + 259 + it('image items use TYPE_ICONS.image', () => { 260 + const item = { type: 'image', content: 'photo.jpg' }; 261 + const info = getItemDisplayInfo(item); 262 + assert.equal(info.faviconUrl, TYPE_ICONS.image); 263 + }); 264 + 265 + it('entity items use TYPE_ICONS.entity', () => { 266 + const item = { type: 'entity', title: 'Test Entity' }; 267 + const info = getItemDisplayInfo(item); 268 + assert.equal(info.faviconUrl, TYPE_ICONS.entity); 269 + }); 270 + 271 + it('feed items use TYPE_ICONS.feed', () => { 272 + const item = { type: 'feed', title: 'RSS Feed' }; 273 + const info = getItemDisplayInfo(item); 274 + assert.equal(info.faviconUrl, TYPE_ICONS.feed); 275 + }); 276 + 277 + it('series items use TYPE_ICONS.series', () => { 278 + const item = { type: 'series', title: 'My Series' }; 279 + const info = getItemDisplayInfo(item); 280 + assert.equal(info.faviconUrl, TYPE_ICONS.series); 281 + }); 282 + }); 283 + }); 284 + 285 + // =========================================================================== 286 + // Tests: getItemDisplayInfo — title and subtitle extraction 287 + // =========================================================================== 288 + 289 + describe('getItemDisplayInfo title extraction', () => { 290 + it('uses item.title for URL items', () => { 291 + const item = { type: 'url', content: 'https://example.com', title: 'Example Site' }; 292 + const info = getItemDisplayInfo(item); 293 + assert.equal(info.title, 'Example Site'); 294 + }); 295 + 296 + it('falls back to URL content when no title', () => { 297 + const item = { type: 'url', content: 'https://example.com/path' }; 298 + const info = getItemDisplayInfo(item); 299 + assert.equal(info.title, 'https://example.com/path'); 300 + }); 301 + 302 + it('extracts title from metadata JSON string', () => { 303 + const item = { type: 'url', content: 'https://x.com', metadata: JSON.stringify({ title: 'Meta Title' }) }; 304 + const info = getItemDisplayInfo(item); 305 + assert.equal(info.title, 'Meta Title'); 306 + }); 307 + 308 + it('extracts title from metadata object', () => { 309 + const item = { type: 'url', content: 'https://x.com', metadata: { title: 'Object Title' } }; 310 + const info = getItemDisplayInfo(item); 311 + assert.equal(info.title, 'Object Title'); 312 + }); 313 + 314 + it('truncates long text content to 100 chars for text items', () => { 315 + const longText = 'A'.repeat(150); 316 + const item = { type: 'text', content: longText }; 317 + const info = getItemDisplayInfo(item); 318 + assert.equal(info.title, 'A'.repeat(100) + '...'); 319 + }); 320 + 321 + it('does not add ellipsis for short text items', () => { 322 + const item = { type: 'text', content: 'short note' }; 323 + const info = getItemDisplayInfo(item); 324 + assert.equal(info.title, 'short note'); 325 + }); 326 + 327 + it('formats URL subtitle for URL items', () => { 328 + const item = { type: 'url', content: 'https://example.com/page?q=1' }; 329 + const info = getItemDisplayInfo(item); 330 + assert.equal(info.subtitle, 'example.com/page'); 331 + }); 332 + 333 + it('returns (untitled) when everything is empty', () => { 334 + const item = { type: 'url' }; 335 + const info = getItemDisplayInfo(item); 336 + assert.equal(info.title, '(untitled)'); 337 + }); 338 + 339 + it('tagset subtitle shows tag names', () => { 340 + const item = { type: 'tagset' }; 341 + const info = getItemDisplayInfo(item, { tags: [{ name: 'foo' }, { name: 'bar' }] }); 342 + assert.equal(info.subtitle, 'foo, bar'); 343 + }); 344 + 345 + it('tagset subtitle shows "Empty tagset" when no tags', () => { 346 + const item = { type: 'tagset' }; 347 + const info = getItemDisplayInfo(item); 348 + assert.equal(info.subtitle, 'Empty tagset'); 349 + }); 350 + 351 + it('entity subtitle capitalizes entityType from metadata', () => { 352 + const item = { type: 'entity', title: 'E', metadata: JSON.stringify({ entityType: 'named_entity' }) }; 353 + const info = getItemDisplayInfo(item); 354 + assert.equal(info.subtitle, 'Named entity'); 355 + }); 356 + }); 357 + 358 + // =========================================================================== 359 + // Tests: getItemDisplayInfo — itemUrl computation 360 + // =========================================================================== 361 + 362 + describe('getItemDisplayInfo itemUrl', () => { 363 + it('returns uri for address items', () => { 364 + const item = { uri: 'https://example.com' }; 365 + const info = getItemDisplayInfo(item); 366 + assert.equal(info.itemUrl, 'https://example.com'); 367 + }); 368 + 369 + it('returns content for URL type items', () => { 370 + const item = { type: 'url', content: 'https://example.com/page' }; 371 + const info = getItemDisplayInfo(item); 372 + assert.equal(info.itemUrl, 'https://example.com/page'); 373 + }); 374 + 375 + it('returns extracted URL for text items containing a URL', () => { 376 + const item = { type: 'text', content: 'https://example.com' }; 377 + const info = getItemDisplayInfo(item); 378 + assert.equal(info.itemUrl, 'https://example.com'); 379 + }); 380 + 381 + it('returns null for text items without URL', () => { 382 + const item = { type: 'text', content: 'just a note' }; 383 + const info = getItemDisplayInfo(item); 384 + assert.equal(info.itemUrl, null); 385 + }); 386 + 387 + it('returns null for image items', () => { 388 + const item = { type: 'image', content: 'photo.jpg' }; 389 + const info = getItemDisplayInfo(item); 390 + assert.equal(info.itemUrl, null); 391 + }); 392 + }); 393 + 394 + // =========================================================================== 395 + // Tests: extractUrl helper 396 + // =========================================================================== 397 + 398 + describe('extractUrl', () => { 399 + it('extracts https URL', () => { 400 + assert.equal(extractUrl('https://example.com'), 'https://example.com'); 401 + }); 402 + 403 + it('extracts http URL', () => { 404 + assert.equal(extractUrl('http://example.com/path'), 'http://example.com/path'); 405 + }); 406 + 407 + it('adds https to bare domain', () => { 408 + assert.equal(extractUrl('example.com'), 'https://example.com'); 409 + }); 410 + 411 + it('adds https to domain with path', () => { 412 + assert.equal(extractUrl('example.com/page'), 'https://example.com/page'); 413 + }); 414 + 415 + it('returns null for plain text', () => { 416 + assert.equal(extractUrl('just some text'), null); 417 + }); 418 + 419 + it('returns null for null', () => { 420 + assert.equal(extractUrl(null), null); 421 + }); 422 + 423 + it('returns null for empty string', () => { 424 + assert.equal(extractUrl(''), null); 425 + }); 426 + 427 + it('trims whitespace', () => { 428 + assert.equal(extractUrl(' https://example.com '), 'https://example.com'); 429 + }); 430 + });
+323
tests/unit/page-maximize-layout.test.js
··· 1 + /** 2 + * Unit tests for page maximize layout math. 3 + * 4 + * Tests the pure computation functions from app/page/page.js: 5 + * - computeWindowBounds() in normal vs maximized mode 6 + * - positionPanelsMaximized() panel position clamping 7 + * - updatePositionsMaximized() element positions 8 + * 9 + * Functions are duplicated here since page.js depends on DOM and IPC. 10 + * 11 + * Run via: node --test tests/unit/page-maximize-layout.test.js 12 + */ 13 + import { describe, it } from 'node:test'; 14 + import { strict as assert } from 'node:assert'; 15 + 16 + // --------------------------------------------------------------------------- 17 + // Constants from page.js 18 + // --------------------------------------------------------------------------- 19 + const NAVBAR_HEIGHT = 36; 20 + const NAVBAR_GAP = 0; 21 + const MARGIN = 8; 22 + const TRIGGER_ZONE_HEIGHT = 8; 23 + const PANEL_WIDTH = 280; 24 + const PANEL_OVERLAP = PANEL_WIDTH * 0.25; // 70 25 + const PANEL_OVERHANG = PANEL_WIDTH - PANEL_OVERLAP; // 210 26 + 27 + // --------------------------------------------------------------------------- 28 + // Pure functions extracted from page.js 29 + // --------------------------------------------------------------------------- 30 + 31 + function computeWindowBounds(sb, isMaximized, preMaximizeBounds, extraWidth) { 32 + if (isMaximized && preMaximizeBounds) { 33 + return { 34 + x: Math.round(sb.x), 35 + y: Math.round(sb.y), 36 + width: Math.round(sb.width), 37 + height: Math.round(sb.height), 38 + }; 39 + } 40 + const winX = sb.x - MARGIN - extraWidth / 2; 41 + const winY = sb.y - TRIGGER_ZONE_HEIGHT - (NAVBAR_HEIGHT + NAVBAR_GAP); 42 + const winW = sb.width + MARGIN * 2 + extraWidth; 43 + const winH = sb.height + TRIGGER_ZONE_HEIGHT + MARGIN + (NAVBAR_HEIGHT + NAVBAR_GAP); 44 + 45 + return { x: Math.round(winX), y: Math.round(winY), width: Math.round(winW), height: Math.round(winH) }; 46 + } 47 + 48 + /** 49 + * Compute maximized element positions (webview, navbar, panels). 50 + * Returns a plain object describing where each element would be placed. 51 + */ 52 + function computeMaximizedPositions(winW, winH, navVisible) { 53 + const webview = { left: 0, top: 0, width: winW, height: winH }; 54 + const navbar = { left: MARGIN, top: MARGIN, width: winW - MARGIN * 2 }; 55 + const triggerZone = { 56 + left: 0, 57 + top: 0, 58 + width: winW, 59 + height: navVisible ? NAVBAR_HEIGHT + MARGIN + TRIGGER_ZONE_HEIGHT : TRIGGER_ZONE_HEIGHT + MARGIN, 60 + }; 61 + return { webview, navbar, triggerZone }; 62 + } 63 + 64 + /** 65 + * Compute panel positions in maximized mode. 66 + * Left panels clamped to MARGIN from left, right panels clamped to MARGIN from right. 67 + */ 68 + function computePanelPositionsMaximized(winW, winH, navVisible) { 69 + const panelTopOffset = (navVisible ? NAVBAR_HEIGHT + MARGIN : 0) + TRIGGER_ZONE_HEIGHT + 40; 70 + 71 + const leftPanelLeft = MARGIN; 72 + const rightPanelLeft = winW - MARGIN - PANEL_WIDTH; 73 + 74 + return { panelTopOffset, leftPanelLeft, rightPanelLeft }; 75 + } 76 + 77 + /** 78 + * Compute panel positions in normal (non-maximized) mode. 79 + * Left panels overhang to the left, right panels overhang to the right. 80 + */ 81 + function computePanelPositionsNormal(webviewLeft, webviewWidth) { 82 + const leftPanelLeft = webviewLeft - PANEL_WIDTH + PANEL_OVERLAP; 83 + const rightPanelLeft = webviewLeft + webviewWidth - PANEL_OVERLAP; 84 + return { leftPanelLeft, rightPanelLeft }; 85 + } 86 + 87 + // =========================================================================== 88 + // Tests: computeWindowBounds 89 + // =========================================================================== 90 + 91 + describe('computeWindowBounds', () => { 92 + describe('normal mode', () => { 93 + it('computes correct window position from screen bounds', () => { 94 + const sb = { x: 100, y: 100, width: 800, height: 600 }; 95 + const bounds = computeWindowBounds(sb, false, null, 0); 96 + // winX = 100 - 8 - 0 = 92 97 + // winY = 100 - 8 - 36 = 56 98 + // winW = 800 + 16 + 0 = 816 99 + // winH = 600 + 8 + 8 + 36 = 652 100 + assert.equal(bounds.x, 92); 101 + assert.equal(bounds.y, 56); 102 + assert.equal(bounds.width, 816); 103 + assert.equal(bounds.height, 652); 104 + }); 105 + 106 + it('adds extra width symmetrically', () => { 107 + const sb = { x: 100, y: 100, width: 800, height: 600 }; 108 + const bounds = computeWindowBounds(sb, false, null, 420); 109 + // winX = 100 - 8 - 210 = -118 110 + // winW = 800 + 16 + 420 = 1236 111 + assert.equal(bounds.x, -118); 112 + assert.equal(bounds.width, 1236); 113 + // Height unaffected by extraWidth 114 + assert.equal(bounds.height, 652); 115 + }); 116 + 117 + it('rounds fractional values', () => { 118 + const sb = { x: 100.3, y: 200.7, width: 800.5, height: 600.1 }; 119 + const bounds = computeWindowBounds(sb, false, null, 0); 120 + assert.equal(bounds.x, Math.round(100.3 - 8)); 121 + assert.equal(bounds.y, Math.round(200.7 - 8 - 36)); 122 + assert.equal(bounds.width, Math.round(800.5 + 16)); 123 + assert.equal(bounds.height, Math.round(600.1 + 8 + 8 + 36)); 124 + }); 125 + 126 + it('height includes navbar, trigger zone, and bottom margin', () => { 127 + const sb = { x: 0, y: 0, width: 1000, height: 500 }; 128 + const bounds = computeWindowBounds(sb, false, null, 0); 129 + // winH = 500 + TRIGGER_ZONE_HEIGHT(8) + MARGIN(8) + NAVBAR_HEIGHT(36) + NAVBAR_GAP(0) 130 + assert.equal(bounds.height, 500 + 8 + 8 + 36); 131 + }); 132 + }); 133 + 134 + describe('maximized mode', () => { 135 + it('returns screen bounds directly when maximized', () => { 136 + const sb = { x: 0, y: 25, width: 1440, height: 875 }; 137 + const preMaxBounds = { x: 100, y: 100, width: 800, height: 600 }; 138 + const bounds = computeWindowBounds(sb, true, preMaxBounds, 0); 139 + assert.equal(bounds.x, 0); 140 + assert.equal(bounds.y, 25); 141 + assert.equal(bounds.width, 1440); 142 + assert.equal(bounds.height, 875); 143 + }); 144 + 145 + it('ignores extraWidth when maximized', () => { 146 + const sb = { x: 0, y: 25, width: 1440, height: 875 }; 147 + const preMaxBounds = { x: 100, y: 100, width: 800, height: 600 }; 148 + const bounds = computeWindowBounds(sb, true, preMaxBounds, 420); 149 + // Should still be exactly the screen bounds 150 + assert.equal(bounds.width, 1440); 151 + assert.equal(bounds.height, 875); 152 + }); 153 + 154 + it('falls back to normal mode when preMaximizeBounds is null', () => { 155 + const sb = { x: 100, y: 100, width: 800, height: 600 }; 156 + // isMaximized=true but preMaximizeBounds=null -> normal computation 157 + const bounds = computeWindowBounds(sb, true, null, 0); 158 + assert.equal(bounds.x, 92); 159 + assert.equal(bounds.y, 56); 160 + }); 161 + 162 + it('rounds screen bounds values', () => { 163 + const sb = { x: 0.4, y: 24.8, width: 1440.3, height: 875.6 }; 164 + const preMaxBounds = { x: 100, y: 100, width: 800, height: 600 }; 165 + const bounds = computeWindowBounds(sb, true, preMaxBounds, 0); 166 + assert.equal(bounds.x, 0); 167 + assert.equal(bounds.y, 25); 168 + assert.equal(bounds.width, 1440); 169 + assert.equal(bounds.height, 876); 170 + }); 171 + }); 172 + }); 173 + 174 + // =========================================================================== 175 + // Tests: maximized element positions 176 + // =========================================================================== 177 + 178 + describe('computeMaximizedPositions', () => { 179 + it('webview fills entire window', () => { 180 + const { webview } = computeMaximizedPositions(1440, 875, false); 181 + assert.equal(webview.left, 0); 182 + assert.equal(webview.top, 0); 183 + assert.equal(webview.width, 1440); 184 + assert.equal(webview.height, 875); 185 + }); 186 + 187 + it('navbar floats with margin when maximized', () => { 188 + const { navbar } = computeMaximizedPositions(1440, 875, true); 189 + assert.equal(navbar.left, MARGIN); 190 + assert.equal(navbar.top, MARGIN); 191 + assert.equal(navbar.width, 1440 - MARGIN * 2); 192 + }); 193 + 194 + it('trigger zone spans full width', () => { 195 + const { triggerZone } = computeMaximizedPositions(1440, 875, false); 196 + assert.equal(triggerZone.left, 0); 197 + assert.equal(triggerZone.top, 0); 198 + assert.equal(triggerZone.width, 1440); 199 + }); 200 + 201 + it('trigger zone taller when navbar visible', () => { 202 + const navHidden = computeMaximizedPositions(1440, 875, false); 203 + const navVisible = computeMaximizedPositions(1440, 875, true); 204 + assert.ok(navVisible.triggerZone.height > navHidden.triggerZone.height); 205 + assert.equal(navVisible.triggerZone.height, NAVBAR_HEIGHT + MARGIN + TRIGGER_ZONE_HEIGHT); 206 + assert.equal(navHidden.triggerZone.height, TRIGGER_ZONE_HEIGHT + MARGIN); 207 + }); 208 + }); 209 + 210 + // =========================================================================== 211 + // Tests: panel positions — maximized vs normal 212 + // =========================================================================== 213 + 214 + describe('computePanelPositionsMaximized', () => { 215 + it('left panels clamped at MARGIN from left edge', () => { 216 + const { leftPanelLeft } = computePanelPositionsMaximized(1440, 875, true); 217 + assert.equal(leftPanelLeft, MARGIN); 218 + }); 219 + 220 + it('right panels clamped so right edge is MARGIN from window right', () => { 221 + const { rightPanelLeft } = computePanelPositionsMaximized(1440, 875, true); 222 + assert.equal(rightPanelLeft, 1440 - MARGIN - PANEL_WIDTH); 223 + // Verify right edge 224 + assert.equal(rightPanelLeft + PANEL_WIDTH, 1440 - MARGIN); 225 + }); 226 + 227 + it('panel top offset accounts for navbar when visible', () => { 228 + const visible = computePanelPositionsMaximized(1440, 875, true); 229 + const hidden = computePanelPositionsMaximized(1440, 875, false); 230 + assert.equal(visible.panelTopOffset, NAVBAR_HEIGHT + MARGIN + TRIGGER_ZONE_HEIGHT + 40); 231 + assert.equal(hidden.panelTopOffset, TRIGGER_ZONE_HEIGHT + 40); 232 + }); 233 + 234 + it('works for different window sizes', () => { 235 + const small = computePanelPositionsMaximized(800, 600, true); 236 + const large = computePanelPositionsMaximized(2560, 1440, true); 237 + // Left panels always at MARGIN 238 + assert.equal(small.leftPanelLeft, MARGIN); 239 + assert.equal(large.leftPanelLeft, MARGIN); 240 + // Right panels scale with window width 241 + assert.equal(small.rightPanelLeft, 800 - MARGIN - PANEL_WIDTH); 242 + assert.equal(large.rightPanelLeft, 2560 - MARGIN - PANEL_WIDTH); 243 + }); 244 + }); 245 + 246 + describe('computePanelPositionsNormal vs maximized', () => { 247 + it('normal left panels overhang beyond window edge', () => { 248 + const webviewLeft = MARGIN; 249 + const { leftPanelLeft } = computePanelPositionsNormal(webviewLeft, 800); 250 + // MARGIN(8) - PANEL_WIDTH(280) + PANEL_OVERLAP(70) = -202 251 + assert.equal(leftPanelLeft, -202); 252 + assert.ok(leftPanelLeft < 0, 'left panel extends past window left edge in normal mode'); 253 + }); 254 + 255 + it('maximized left panels stay within window', () => { 256 + const { leftPanelLeft } = computePanelPositionsMaximized(1440, 875, true); 257 + assert.ok(leftPanelLeft >= 0, 'left panel stays within window in maximized mode'); 258 + }); 259 + 260 + it('normal right panels overhang beyond window right edge', () => { 261 + const webviewLeft = MARGIN; 262 + const webviewWidth = 800; 263 + const { rightPanelLeft } = computePanelPositionsNormal(webviewLeft, webviewWidth); 264 + // MARGIN(8) + 800 - PANEL_OVERLAP(70) = 738 265 + assert.equal(rightPanelLeft, 738); 266 + // Panel right edge = 738 + 280 = 1018, but window width = 800 + 16 = 816 267 + assert.ok(rightPanelLeft + PANEL_WIDTH > webviewWidth + MARGIN * 2, 'right panel extends past window right edge in normal mode'); 268 + }); 269 + 270 + it('maximized right panels stay within window', () => { 271 + const winW = 1440; 272 + const { rightPanelLeft } = computePanelPositionsMaximized(winW, 875, true); 273 + assert.ok(rightPanelLeft + PANEL_WIDTH <= winW, 'right panel stays within window in maximized mode'); 274 + }); 275 + }); 276 + 277 + // =========================================================================== 278 + // Tests: maximize toggle state transitions 279 + // =========================================================================== 280 + 281 + describe('maximize state transitions', () => { 282 + it('pre-maximize bounds are preserved for restore', () => { 283 + const originalBounds = { x: 100, y: 100, width: 800, height: 600 }; 284 + const workArea = { x: 0, y: 25, width: 1440, height: 875 }; 285 + 286 + // Simulate maximize 287 + const preMaximizeBounds = { ...originalBounds }; 288 + const maximizedScreenBounds = { ...workArea }; 289 + const windowBounds = computeWindowBounds(maximizedScreenBounds, true, preMaximizeBounds, 0); 290 + 291 + assert.equal(windowBounds.width, workArea.width); 292 + assert.equal(windowBounds.height, workArea.height); 293 + 294 + // Simulate restore 295 + const restoredBounds = computeWindowBounds(originalBounds, false, null, 0); 296 + assert.equal(restoredBounds.width, originalBounds.width + MARGIN * 2); 297 + assert.equal(restoredBounds.height, originalBounds.height + TRIGGER_ZONE_HEIGHT + MARGIN + NAVBAR_HEIGHT); 298 + }); 299 + 300 + it('drag-out restore centers on cursor position', () => { 301 + const preMaxBounds = { x: 100, y: 100, width: 800, height: 600 }; 302 + const cursorScreenX = 500; 303 + const cursorScreenY = 300; 304 + 305 + // Simulate drag-out restore (from startDrag in page.js) 306 + const restoredScreenBounds = { 307 + x: cursorScreenX - preMaxBounds.width / 2, 308 + y: cursorScreenY, 309 + width: preMaxBounds.width, 310 + height: preMaxBounds.height, 311 + }; 312 + 313 + assert.equal(restoredScreenBounds.x, 100); // 500 - 400 314 + assert.equal(restoredScreenBounds.y, 300); 315 + assert.equal(restoredScreenBounds.width, 800); 316 + assert.equal(restoredScreenBounds.height, 600); 317 + 318 + // Window bounds from restored screen bounds 319 + const windowBounds = computeWindowBounds(restoredScreenBounds, false, null, 0); 320 + assert.equal(windowBounds.x, 100 - MARGIN); 321 + assert.equal(windowBounds.y, 300 - TRIGGER_ZONE_HEIGHT - NAVBAR_HEIGHT); 322 + }); 323 + });