experiments in a post-browser web
10
fork

Configure Feed

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

feat: groups UI improvements and page layout tests

+1043
+78
features/groups/home.css
··· 331 331 background: var(--base01); 332 332 } 333 333 334 + /* Group detail header — shown when viewing a group's contents */ 335 + .group-detail-header { 336 + grid-column: 1 / -1; 337 + display: flex; 338 + align-items: center; 339 + gap: 10px; 340 + padding: 4px 0 10px 0; 341 + margin-bottom: 4px; 342 + border-bottom: 1px solid var(--base02); 343 + } 344 + 345 + .group-header-dot { 346 + width: 12px; 347 + height: 12px; 348 + border-radius: 50%; 349 + flex-shrink: 0; 350 + } 351 + 352 + .group-header-name { 353 + font-size: 16px; 354 + font-weight: 600; 355 + color: var(--base05); 356 + cursor: default; 357 + white-space: nowrap; 358 + overflow: hidden; 359 + text-overflow: ellipsis; 360 + min-width: 0; 361 + flex: 1; 362 + } 363 + 364 + .group-header-name[title] { 365 + cursor: pointer; 366 + } 367 + 368 + .group-header-name[title]:hover { 369 + color: var(--base0D); 370 + } 371 + 372 + .group-rename-btn { 373 + display: flex; 374 + align-items: center; 375 + justify-content: center; 376 + width: 24px; 377 + height: 24px; 378 + background: transparent; 379 + border: none; 380 + border-radius: 4px; 381 + cursor: pointer; 382 + color: var(--base04); 383 + flex-shrink: 0; 384 + transition: all 0.15s; 385 + padding: 0; 386 + } 387 + 388 + .group-rename-btn:hover { 389 + color: var(--base05); 390 + background: var(--base02); 391 + } 392 + 393 + .group-rename-input { 394 + font-size: 16px; 395 + font-weight: 600; 396 + color: var(--base05); 397 + background: var(--base01); 398 + border: 1px solid var(--base0D); 399 + border-radius: 4px; 400 + padding: 2px 8px; 401 + outline: none; 402 + flex: 1; 403 + min-width: 0; 404 + font-family: var(--theme-font-sans); 405 + } 406 + 407 + .group-rename-input:focus { 408 + border-color: var(--base0D); 409 + box-shadow: 0 0 0 1px var(--base0D); 410 + } 411 + 334 412 /* Unpromoted tag cards — slightly dimmed */ 335 413 peek-card.unpromoted-tag { 336 414 opacity: 0.7;
+120
features/groups/home.js
··· 439 439 debouncedRefresh(); 440 440 }, api.scopes.GLOBAL); 441 441 442 + // Subscribe to tag rename events for reactive updates 443 + api.subscribe('tag:renamed', (msg) => { 444 + debug && console.log('[groups] tag:renamed event received:', msg); 445 + debouncedRefresh(); 446 + }, api.scopes.GLOBAL); 447 + 442 448 // Subscribe to pin change events for reactive updates 443 449 api.subscribe('group:pin-changed', (msg) => { 444 450 debug && console.log('[groups] group:pin-changed event received:', msg); ··· 766 772 }; 767 773 768 774 /** 775 + * Render the group detail header with group name and rename support. 776 + * Double-click the name to rename. 777 + */ 778 + const renderGroupHeader = () => { 779 + const container = document.querySelector('.cards'); 780 + const tag = state.currentTag; 781 + if (!tag) return; 782 + 783 + // Remove existing header if present 784 + const existing = container.querySelector('.group-detail-header'); 785 + if (existing) existing.remove(); 786 + 787 + const header = document.createElement('div'); 788 + header.className = 'group-detail-header'; 789 + 790 + const colorDot = document.createElement('div'); 791 + colorDot.className = 'group-header-dot'; 792 + colorDot.style.backgroundColor = tag.color || '#999'; 793 + header.appendChild(colorDot); 794 + 795 + const nameEl = document.createElement('span'); 796 + nameEl.className = 'group-header-name'; 797 + nameEl.textContent = tag.name; 798 + // Double-click to rename (not for special groups like Untagged) 799 + if (!tag.isSpecial) { 800 + nameEl.title = 'Double-click to rename'; 801 + nameEl.addEventListener('dblclick', () => startRenameGroup(header, tag)); 802 + } 803 + header.appendChild(nameEl); 804 + 805 + // Rename button (not for special groups) 806 + if (!tag.isSpecial) { 807 + const renameBtn = document.createElement('button'); 808 + renameBtn.className = 'group-rename-btn'; 809 + renameBtn.title = 'Rename group'; 810 + renameBtn.innerHTML = 811 + '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + 812 + '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>' + 813 + '<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>' + 814 + '</svg>'; 815 + renameBtn.addEventListener('click', () => startRenameGroup(header, tag)); 816 + header.appendChild(renameBtn); 817 + } 818 + 819 + container.insertBefore(header, container.firstChild); 820 + }; 821 + 822 + /** 823 + * Start inline rename of a group 824 + */ 825 + const startRenameGroup = (headerEl, tag) => { 826 + // Replace name element with input 827 + const nameEl = headerEl.querySelector('.group-header-name'); 828 + const renameBtn = headerEl.querySelector('.group-rename-btn'); 829 + if (!nameEl) return; 830 + 831 + const input = document.createElement('input'); 832 + input.className = 'group-rename-input'; 833 + input.type = 'text'; 834 + input.value = tag.name; 835 + 836 + const finishRename = async (commit) => { 837 + const newName = input.value.trim(); 838 + if (commit && newName && newName !== tag.name) { 839 + try { 840 + const result = await api.datastore.renameTag(tag.id, newName); 841 + if (result.success) { 842 + tag.name = newName; 843 + // Update search placeholder 844 + const searchInput = document.querySelector('peek-input.search-input'); 845 + searchInput.placeholder = `Search in ${newName}...`; 846 + // Update mode context 847 + if (api.context) { 848 + await api.context.setMode('group', { 849 + metadata: { groupId: tag.id, groupName: newName, color: tag.color } 850 + }); 851 + } 852 + debug && console.log('[groups] Renamed group to:', newName); 853 + } else { 854 + console.error('[groups] Failed to rename group:', result.error); 855 + } 856 + } catch (err) { 857 + console.error('[groups] Error renaming group:', err); 858 + } 859 + } 860 + // Restore display 861 + nameEl.textContent = tag.name; 862 + nameEl.style.display = ''; 863 + input.remove(); 864 + if (renameBtn) renameBtn.style.display = ''; 865 + }; 866 + 867 + nameEl.style.display = 'none'; 868 + if (renameBtn) renameBtn.style.display = 'none'; 869 + headerEl.insertBefore(input, nameEl.nextSibling); 870 + input.focus(); 871 + input.select(); 872 + 873 + input.addEventListener('keydown', (e) => { 874 + if (e.key === 'Enter') { 875 + e.preventDefault(); 876 + finishRename(true); 877 + } else if (e.key === 'Escape') { 878 + e.preventDefault(); 879 + finishRename(false); 880 + } 881 + }); 882 + input.addEventListener('blur', () => finishRename(true)); 883 + }; 884 + 885 + /** 769 886 * Render address cards (separate from showAddresses for filtering) 770 887 */ 771 888 const renderAddresses = () => { 772 889 const container = document.querySelector('.cards'); 773 890 container.innerHTML = ''; 774 891 updateToolbarSortOptions(); 892 + 893 + // Render group detail header 894 + renderGroupHeader(); 775 895 776 896 // Apply search filter 777 897 let filteredAddresses = filterAddresses(state.addresses);
+845
tests/desktop/page-layout.spec.ts
··· 1 + /** 2 + * Page Host Layout Tests 3 + * 4 + * Tests the page host layout invariants and future maximize behavior: 5 + * - Regression tests (1-5): Verify current layout math is correct 6 + * - New behavior tests (6-14): Verify maximize functionality (skipped until implemented) 7 + * 8 + * Constants from page.js: 9 + * NAVBAR_HEIGHT = 36, NAVBAR_GAP = 0, TRIGGER_ZONE_HEIGHT = 8, MARGIN = 8 10 + * PANEL_WIDTH = 280, PANEL_OVERLAP = 70, PANEL_OVERHANG = 210 11 + * 12 + * Run with: 13 + * BACKEND=electron yarn test:electron 14 + */ 15 + 16 + import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 17 + import { Page } from '@playwright/test'; 18 + import { waitForExtensionsReady } from '../helpers/window-utils'; 19 + 20 + // Layout constants (must match page.js) 21 + const NAVBAR_HEIGHT = 36; 22 + const NAVBAR_GAP = 0; 23 + const TRIGGER_ZONE_HEIGHT = 8; 24 + const MARGIN = 8; 25 + const PANEL_WIDTH = 280; 26 + const PANEL_OVERLAP = 70; // PANEL_WIDTH * 0.25 27 + const PANEL_OVERHANG = 210; // PANEL_WIDTH - PANEL_OVERLAP 28 + 29 + // Derived constants 30 + const NAV_OFFSET = NAVBAR_HEIGHT + NAVBAR_GAP; // 36 31 + const WEBVIEW_TOP = NAV_OFFSET + TRIGGER_ZONE_HEIGHT; // 44 32 + 33 + // Shared app instance 34 + let sharedApp: DesktopApp; 35 + let sharedBgWindow: Page; 36 + 37 + test.beforeAll(async () => { 38 + sharedApp = await getSharedApp(); 39 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 40 + await waitForExtensionsReady(sharedBgWindow); 41 + }); 42 + 43 + test.afterAll(async () => { 44 + await closeSharedApp(); 45 + }); 46 + 47 + // ============================================================================ 48 + // Helpers 49 + // ============================================================================ 50 + 51 + async function openCanvasPage( 52 + bgWindow: Page, 53 + url: string, 54 + options: { width?: number; height?: number } = {} 55 + ): Promise<{ pageWindow: Page; windowId: number }> { 56 + const result = await bgWindow.evaluate( 57 + async ({ targetUrl, opts }: { targetUrl: string; opts: { width?: number; height?: number } }) => { 58 + return await (window as any).app.window.open(targetUrl, { 59 + width: opts.width || 800, 60 + height: opts.height || 600, 61 + ...opts, 62 + }); 63 + }, 64 + { targetUrl: url, opts: options } 65 + ); 66 + expect(result.success).toBe(true); 67 + const windowId = result.id; 68 + 69 + const pageWindow = await sharedApp.getWindow('page/index.html', 15000); 70 + expect(pageWindow).toBeTruthy(); 71 + 72 + return { pageWindow, windowId }; 73 + } 74 + 75 + async function waitForPageLoaded(pageWindow: Page, timeout = 30000): Promise<void> { 76 + await pageWindow.waitForFunction( 77 + () => { 78 + const webview = document.getElementById('content'); 79 + return webview && webview.classList.contains('loaded'); 80 + }, 81 + undefined, 82 + { timeout } 83 + ); 84 + } 85 + 86 + async function waitForPageReady(pageWindow: Page): Promise<void> { 87 + // Wait for DOM elements to exist 88 + await pageWindow.waitForFunction( 89 + () => document.getElementById('navbar') !== null && document.getElementById('content') !== null, 90 + undefined, 91 + { timeout: 10000 } 92 + ); 93 + // Wait for page to finish loading 94 + await waitForPageLoaded(pageWindow); 95 + } 96 + 97 + async function closeWindow(bgWindow: Page, windowId: number): Promise<void> { 98 + await bgWindow.evaluate(async (id: number) => { 99 + return await (window as any).app.window.close(id); 100 + }, windowId); 101 + } 102 + 103 + /** 104 + * Get the computed style properties of an element by ID 105 + */ 106 + async function getElementPosition(pageWindow: Page, elementId: string): Promise<{ 107 + left: number; 108 + top: number; 109 + width: number; 110 + height: number; 111 + }> { 112 + return pageWindow.evaluate((id: string) => { 113 + const el = document.getElementById(id) as HTMLElement; 114 + if (!el) throw new Error(`Element #${id} not found`); 115 + return { 116 + left: parseFloat(el.style.left) || 0, 117 + top: parseFloat(el.style.top) || 0, 118 + width: parseFloat(el.style.width) || 0, 119 + height: parseFloat(el.style.height) || 0, 120 + }; 121 + }, elementId); 122 + } 123 + 124 + /** 125 + * Get the window bounds via the Electron API 126 + */ 127 + async function getWindowBounds(pageWindow: Page): Promise<{ 128 + x: number; 129 + y: number; 130 + width: number; 131 + height: number; 132 + }> { 133 + return pageWindow.evaluate(async () => { 134 + return await (window as any).app.window.getBounds(); 135 + }); 136 + } 137 + 138 + // ============================================================================ 139 + // Regression Tests (1-5): Must pass with current code 140 + // ============================================================================ 141 + 142 + test.describe('Page Layout Regression @desktop', () => { 143 + 144 + // Test 1: Webview positioned below navbar space (44px from window top) 145 + test('webview is positioned 44px from window top', async () => { 146 + const { pageWindow, windowId } = await openCanvasPage( 147 + sharedBgWindow, 148 + 'https://example.com' 149 + ); 150 + 151 + await waitForPageReady(pageWindow); 152 + 153 + const webviewPos = await getElementPosition(pageWindow, 'content'); 154 + // Webview top = NAVBAR_HEIGHT(36) + NAVBAR_GAP(0) + TRIGGER_ZONE_HEIGHT(8) = 44 155 + expect(webviewPos.top).toBe(WEBVIEW_TOP); 156 + // Webview left = MARGIN(8) 157 + expect(webviewPos.left).toBe(MARGIN); 158 + 159 + await closeWindow(sharedBgWindow, windowId); 160 + }); 161 + 162 + // Test 2: Navbar show/hide does not change window height 163 + // The window always reserves 44px for navbar space. Show/hide toggles 164 + // CSS-only (opacity + pointer-events). The only setBounds calls during 165 + // show/hide are for width changes (panel overhang). Height stays the same. 166 + test('navbar show/hide does not change window height', async () => { 167 + const { pageWindow, windowId } = await openCanvasPage( 168 + sharedBgWindow, 169 + 'https://example.com' 170 + ); 171 + 172 + await waitForPageReady(pageWindow); 173 + 174 + // Show navbar via pubsub — this triggers setWindowPadding which calls 175 + // setBounds with computeWindowBounds. This establishes the proper window 176 + // height (screenBounds.height + 44 + 8). 177 + await sharedBgWindow.evaluate(async (wid: number) => { 178 + (window as any).app.publish( 179 + 'page:show-navbar', 180 + { windowId: wid }, 181 + (window as any).app.scopes.GLOBAL 182 + ); 183 + }, windowId); 184 + 185 + // Wait for navbar to become visible 186 + await pageWindow.waitForFunction( 187 + () => { 188 + const navbar = document.getElementById('navbar'); 189 + return navbar && navbar.classList.contains('visible'); 190 + }, 191 + undefined, 192 + { timeout: 5000 } 193 + ); 194 + 195 + // Record the window height when navbar is shown 196 + const shownBounds = await getWindowBounds(pageWindow); 197 + 198 + // Simulate mouseleave to hide navbar 199 + await pageWindow.evaluate(() => { 200 + const navbar = document.getElementById('navbar'); 201 + if (navbar) { 202 + navbar.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true, clientY: 200 })); 203 + } 204 + document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 200 })); 205 + }); 206 + 207 + // Wait for navbar to hide 208 + await pageWindow.waitForFunction( 209 + () => { 210 + const navbar = document.getElementById('navbar'); 211 + return navbar && !navbar.classList.contains('visible'); 212 + }, 213 + undefined, 214 + { timeout: 5000 } 215 + ); 216 + 217 + // Window height must be the same after hide — only width changes (panel padding) 218 + const hiddenBounds = await getWindowBounds(pageWindow); 219 + expect(hiddenBounds.height).toBe(shownBounds.height); 220 + 221 + // Show again and verify height still matches 222 + await sharedBgWindow.evaluate(async (wid: number) => { 223 + (window as any).app.publish( 224 + 'page:show-navbar', 225 + { windowId: wid }, 226 + (window as any).app.scopes.GLOBAL 227 + ); 228 + }, windowId); 229 + 230 + await pageWindow.waitForFunction( 231 + () => { 232 + const navbar = document.getElementById('navbar'); 233 + return navbar && navbar.classList.contains('visible'); 234 + }, 235 + undefined, 236 + { timeout: 5000 } 237 + ); 238 + 239 + const shownAgainBounds = await getWindowBounds(pageWindow); 240 + expect(shownAgainBounds.height).toBe(shownBounds.height); 241 + 242 + await closeWindow(sharedBgWindow, windowId); 243 + }); 244 + 245 + // Test 3: Panel positions — left panels partially off-left, right panels partially off-right 246 + test('panel positions: left panels offset left, right panels offset right', async () => { 247 + const { pageWindow, windowId } = await openCanvasPage( 248 + sharedBgWindow, 249 + 'https://example.com' 250 + ); 251 + 252 + await waitForPageReady(pageWindow); 253 + 254 + // Show navbar to make panels visible (panels share navbar lifecycle) 255 + await sharedBgWindow.evaluate(async (wid: number) => { 256 + (window as any).app.publish( 257 + 'page:show-navbar', 258 + { windowId: wid }, 259 + (window as any).app.scopes.GLOBAL 260 + ); 261 + }, windowId); 262 + 263 + await pageWindow.waitForFunction( 264 + () => { 265 + const navbar = document.getElementById('navbar'); 266 + return navbar && navbar.classList.contains('visible'); 267 + }, 268 + undefined, 269 + { timeout: 5000 } 270 + ); 271 + 272 + // Check left panel (page-info-panel) position 273 + // Expected left = webviewLeft(8) - PANEL_WIDTH(280) + PANEL_OVERLAP(70) = -202 274 + const pageInfoPos = await getElementPosition(pageWindow, 'page-info-panel'); 275 + const expectedLeftPanelLeft = MARGIN - PANEL_WIDTH + PANEL_OVERLAP; // 8 - 280 + 70 = -202 276 + expect(pageInfoPos.left).toBe(expectedLeftPanelLeft); 277 + 278 + // Check right panel position (e.g. tags-panel, entities-panel) 279 + // Expected left = webviewLeft(8) + webviewWidth - PANEL_OVERLAP(70) 280 + const webviewPos = await getElementPosition(pageWindow, 'content'); 281 + const expectedRightPanelLeft = MARGIN + webviewPos.width - PANEL_OVERLAP; 282 + 283 + const tagsPanelPos = await getElementPosition(pageWindow, 'tags-panel'); 284 + expect(tagsPanelPos.left).toBe(expectedRightPanelLeft); 285 + 286 + const entitiesPanelPos = await getElementPosition(pageWindow, 'entities-panel'); 287 + expect(entitiesPanelPos.left).toBe(expectedRightPanelLeft); 288 + 289 + await closeWindow(sharedBgWindow, windowId); 290 + }); 291 + 292 + // Test 4: Resize handles at webview corners 293 + test('resize handles positioned at webview corners', async () => { 294 + const { pageWindow, windowId } = await openCanvasPage( 295 + sharedBgWindow, 296 + 'https://example.com' 297 + ); 298 + 299 + await waitForPageReady(pageWindow); 300 + 301 + const webviewPos = await getElementPosition(pageWindow, 'content'); 302 + const handleSize = 24; 303 + 304 + // SE handle: bottom-right corner of webview 305 + const sePos = await getElementPosition(pageWindow, 'resize-se'); 306 + expect(sePos.left).toBe(webviewPos.left + webviewPos.width - handleSize); 307 + expect(sePos.top).toBe(webviewPos.top + webviewPos.height - handleSize); 308 + 309 + // SW handle: bottom-left corner of webview 310 + const swPos = await getElementPosition(pageWindow, 'resize-sw'); 311 + expect(swPos.left).toBe(webviewPos.left); 312 + expect(swPos.top).toBe(webviewPos.top + webviewPos.height - handleSize); 313 + 314 + // NE handle: top-right corner of webview 315 + const nePos = await getElementPosition(pageWindow, 'resize-ne'); 316 + expect(nePos.left).toBe(webviewPos.left + webviewPos.width - handleSize); 317 + expect(nePos.top).toBe(webviewPos.top); 318 + 319 + // NW handle: top-left corner of webview 320 + const nwPos = await getElementPosition(pageWindow, 'resize-nw'); 321 + expect(nwPos.left).toBe(webviewPos.left); 322 + expect(nwPos.top).toBe(webviewPos.top); 323 + 324 + await closeWindow(sharedBgWindow, windowId); 325 + }); 326 + 327 + // Test 5: URL params update on drag 328 + test('URL params update after drag completes', async () => { 329 + const { pageWindow, windowId } = await openCanvasPage( 330 + sharedBgWindow, 331 + 'https://example.com' 332 + ); 333 + 334 + await waitForPageReady(pageWindow); 335 + 336 + // Read initial URL params 337 + const initialParams = await pageWindow.evaluate(() => { 338 + const url = new URL(window.location.href); 339 + return { 340 + x: parseInt(url.searchParams.get('x') || '0'), 341 + y: parseInt(url.searchParams.get('y') || '0'), 342 + width: parseInt(url.searchParams.get('width') || '0'), 343 + height: parseInt(url.searchParams.get('height') || '0'), 344 + }; 345 + }); 346 + 347 + // Simulate a drag by calling startDrag + mousemove + mouseup on the navbar 348 + // The navbar mousedown starts an instant drag 349 + await pageWindow.evaluate(() => { 350 + const navbar = document.getElementById('navbar') as HTMLElement; 351 + // Dispatch mousedown on navbar to start drag 352 + navbar.dispatchEvent(new MouseEvent('mousedown', { 353 + bubbles: true, 354 + screenX: 400, 355 + screenY: 300, 356 + button: 0, 357 + })); 358 + 359 + // Dispatch mousemove to simulate dragging 50px right and 30px down 360 + document.dispatchEvent(new MouseEvent('mousemove', { 361 + bubbles: true, 362 + screenX: 450, 363 + screenY: 330, 364 + })); 365 + 366 + // Dispatch mouseup to end drag 367 + document.dispatchEvent(new MouseEvent('mouseup', { 368 + bubbles: true, 369 + })); 370 + }); 371 + 372 + // Wait for URL params to reflect the new position 373 + await pageWindow.waitForFunction( 374 + (origX: number) => { 375 + const url = new URL(window.location.href); 376 + const newX = parseInt(url.searchParams.get('x') || '0'); 377 + return newX !== origX; 378 + }, 379 + initialParams.x, 380 + { timeout: 5000 } 381 + ); 382 + 383 + // Verify the URL params changed 384 + const updatedParams = await pageWindow.evaluate(() => { 385 + const url = new URL(window.location.href); 386 + return { 387 + x: parseInt(url.searchParams.get('x') || '0'), 388 + y: parseInt(url.searchParams.get('y') || '0'), 389 + }; 390 + }); 391 + 392 + // The x should have increased by ~50 and y by ~30 393 + expect(updatedParams.x).not.toBe(initialParams.x); 394 + expect(updatedParams.y).not.toBe(initialParams.y); 395 + 396 + await closeWindow(sharedBgWindow, windowId); 397 + }); 398 + }); 399 + 400 + // ============================================================================ 401 + // New Behavior Tests (6-14): Will pass after maximize implementation 402 + // These test maximize functionality that doesn't exist yet. 403 + // ============================================================================ 404 + 405 + test.describe('Page Layout Maximize @desktop', () => { 406 + 407 + // Test 6: Maximize command fills work area 408 + // Will pass after maximize implementation 409 + test.skip('maximize command fills work area', async () => { 410 + const { pageWindow, windowId } = await openCanvasPage( 411 + sharedBgWindow, 412 + 'https://example.com' 413 + ); 414 + 415 + await waitForPageReady(pageWindow); 416 + 417 + // Publish maximize command 418 + await sharedBgWindow.evaluate(async (wid: number) => { 419 + (window as any).app.publish( 420 + 'page:maximize', 421 + { windowId: wid }, 422 + (window as any).app.scopes.GLOBAL 423 + ); 424 + }, windowId); 425 + 426 + // Window bounds should match the display work area 427 + const result = await pageWindow.evaluate(async () => { 428 + const bounds = await (window as any).app.window.getBounds(); 429 + const displayInfo = await (window as any).app.invoke('get-display-info'); 430 + return { bounds, workArea: displayInfo.workArea }; 431 + }); 432 + 433 + expect(result.bounds.x).toBe(result.workArea.x); 434 + expect(result.bounds.y).toBe(result.workArea.y); 435 + expect(result.bounds.width).toBe(result.workArea.width); 436 + expect(result.bounds.height).toBe(result.workArea.height); 437 + 438 + await closeWindow(sharedBgWindow, windowId); 439 + }); 440 + 441 + // Test 7: Maximize toggle restores original size 442 + // Will pass after maximize implementation 443 + test.skip('maximize toggle restores original size', async () => { 444 + const { pageWindow, windowId } = await openCanvasPage( 445 + sharedBgWindow, 446 + 'https://example.com' 447 + ); 448 + 449 + await waitForPageReady(pageWindow); 450 + 451 + // Record original bounds 452 + const originalBounds = await getWindowBounds(pageWindow); 453 + 454 + // Maximize 455 + await sharedBgWindow.evaluate(async (wid: number) => { 456 + (window as any).app.publish( 457 + 'page:maximize', 458 + { windowId: wid }, 459 + (window as any).app.scopes.GLOBAL 460 + ); 461 + }, windowId); 462 + 463 + // Wait for maximize state 464 + await pageWindow.waitForFunction( 465 + () => document.body.classList.contains('maximized'), 466 + undefined, 467 + { timeout: 5000 } 468 + ); 469 + 470 + // Toggle back (restore) 471 + await sharedBgWindow.evaluate(async (wid: number) => { 472 + (window as any).app.publish( 473 + 'page:maximize', 474 + { windowId: wid }, 475 + (window as any).app.scopes.GLOBAL 476 + ); 477 + }, windowId); 478 + 479 + // Wait for maximized class to be removed 480 + await pageWindow.waitForFunction( 481 + () => !document.body.classList.contains('maximized'), 482 + undefined, 483 + { timeout: 5000 } 484 + ); 485 + 486 + // Bounds should match original 487 + const restoredBounds = await getWindowBounds(pageWindow); 488 + expect(restoredBounds.width).toBe(originalBounds.width); 489 + expect(restoredBounds.height).toBe(originalBounds.height); 490 + 491 + await closeWindow(sharedBgWindow, windowId); 492 + }); 493 + 494 + // 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 () => { 497 + const { pageWindow, windowId } = await openCanvasPage( 498 + sharedBgWindow, 499 + 'https://example.com' 500 + ); 501 + 502 + await waitForPageReady(pageWindow); 503 + 504 + // Maximize 505 + await sharedBgWindow.evaluate(async (wid: number) => { 506 + (window as any).app.publish( 507 + 'page:maximize', 508 + { windowId: wid }, 509 + (window as any).app.scopes.GLOBAL 510 + ); 511 + }, windowId); 512 + 513 + await pageWindow.waitForFunction( 514 + () => document.body.classList.contains('maximized'), 515 + undefined, 516 + { timeout: 5000 } 517 + ); 518 + 519 + // Webview should be at (0, 0) filling the window 520 + const webviewPos = await getElementPosition(pageWindow, 'content'); 521 + expect(webviewPos.left).toBe(0); 522 + expect(webviewPos.top).toBe(0); 523 + 524 + await closeWindow(sharedBgWindow, windowId); 525 + }); 526 + 527 + // Test 9: Navbar floats over webview when maximized 528 + // Will pass after maximize implementation 529 + test.skip('navbar floats over webview when maximized', async () => { 530 + const { pageWindow, windowId } = await openCanvasPage( 531 + sharedBgWindow, 532 + 'https://example.com' 533 + ); 534 + 535 + await waitForPageReady(pageWindow); 536 + 537 + // Maximize 538 + await sharedBgWindow.evaluate(async (wid: number) => { 539 + (window as any).app.publish( 540 + 'page:maximize', 541 + { windowId: wid }, 542 + (window as any).app.scopes.GLOBAL 543 + ); 544 + }, windowId); 545 + 546 + await pageWindow.waitForFunction( 547 + () => document.body.classList.contains('maximized'), 548 + undefined, 549 + { timeout: 5000 } 550 + ); 551 + 552 + // Show navbar 553 + await sharedBgWindow.evaluate(async (wid: number) => { 554 + (window as any).app.publish( 555 + 'page:show-navbar', 556 + { windowId: wid }, 557 + (window as any).app.scopes.GLOBAL 558 + ); 559 + }, windowId); 560 + 561 + await pageWindow.waitForFunction( 562 + () => { 563 + const navbar = document.getElementById('navbar'); 564 + return navbar && navbar.classList.contains('visible'); 565 + }, 566 + undefined, 567 + { timeout: 5000 } 568 + ); 569 + 570 + // Navbar should be floating above the webview (z-index check) 571 + // and webview should still be at top=0 (not pushed down) 572 + const webviewPos = await getElementPosition(pageWindow, 'content'); 573 + expect(webviewPos.top).toBe(0); 574 + 575 + // Navbar should have z-index >= 100 (already does from CSS) 576 + const navbarZIndex = await pageWindow.evaluate(() => { 577 + const navbar = document.getElementById('navbar') as HTMLElement; 578 + return parseInt(window.getComputedStyle(navbar).zIndex) || 0; 579 + }); 580 + expect(navbarZIndex).toBeGreaterThanOrEqual(100); 581 + 582 + await closeWindow(sharedBgWindow, windowId); 583 + }); 584 + 585 + // 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 () => { 588 + const { pageWindow, windowId } = await openCanvasPage( 589 + sharedBgWindow, 590 + 'https://example.com' 591 + ); 592 + 593 + await waitForPageReady(pageWindow); 594 + 595 + // Maximize 596 + await sharedBgWindow.evaluate(async (wid: number) => { 597 + (window as any).app.publish( 598 + 'page:maximize', 599 + { windowId: wid }, 600 + (window as any).app.scopes.GLOBAL 601 + ); 602 + }, windowId); 603 + 604 + await pageWindow.waitForFunction( 605 + () => document.body.classList.contains('maximized'), 606 + undefined, 607 + { timeout: 5000 } 608 + ); 609 + 610 + // Show navbar to reveal panels 611 + await sharedBgWindow.evaluate(async (wid: number) => { 612 + (window as any).app.publish( 613 + 'page:show-navbar', 614 + { windowId: wid }, 615 + (window as any).app.scopes.GLOBAL 616 + ); 617 + }, windowId); 618 + 619 + await pageWindow.waitForFunction( 620 + () => { 621 + const navbar = document.getElementById('navbar'); 622 + return navbar && navbar.classList.contains('visible'); 623 + }, 624 + undefined, 625 + { timeout: 5000 } 626 + ); 627 + 628 + // Left panels should be clamped: left >= MARGIN 629 + const pageInfoPos = await getElementPosition(pageWindow, 'page-info-panel'); 630 + expect(pageInfoPos.left).toBeGreaterThanOrEqual(MARGIN); 631 + 632 + // Right panels should be clamped: left + width <= window width - MARGIN 633 + const windowWidth = await pageWindow.evaluate(() => window.innerWidth); 634 + const tagsPanelPos = await getElementPosition(pageWindow, 'tags-panel'); 635 + expect(tagsPanelPos.left + PANEL_WIDTH).toBeLessThanOrEqual(windowWidth - MARGIN); 636 + 637 + await closeWindow(sharedBgWindow, windowId); 638 + }); 639 + 640 + // Test 11: Resize handles hidden when maximized 641 + // Will pass after maximize implementation 642 + test.skip('resize handles hidden when maximized', async () => { 643 + const { pageWindow, windowId } = await openCanvasPage( 644 + sharedBgWindow, 645 + 'https://example.com' 646 + ); 647 + 648 + await waitForPageReady(pageWindow); 649 + 650 + // Maximize 651 + await sharedBgWindow.evaluate(async (wid: number) => { 652 + (window as any).app.publish( 653 + 'page:maximize', 654 + { windowId: wid }, 655 + (window as any).app.scopes.GLOBAL 656 + ); 657 + }, windowId); 658 + 659 + await pageWindow.waitForFunction( 660 + () => document.body.classList.contains('maximized'), 661 + undefined, 662 + { timeout: 5000 } 663 + ); 664 + 665 + // All resize handles should be hidden 666 + const handlesHidden = await pageWindow.evaluate(() => { 667 + const handles = document.querySelectorAll('.resize-handle'); 668 + return Array.from(handles).every((h) => { 669 + const style = window.getComputedStyle(h); 670 + return style.display === 'none' || style.visibility === 'hidden' || style.pointerEvents === 'none'; 671 + }); 672 + }); 673 + expect(handlesHidden).toBe(true); 674 + 675 + await closeWindow(sharedBgWindow, windowId); 676 + }); 677 + 678 + // 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 () => { 681 + const { pageWindow, windowId } = await openCanvasPage( 682 + sharedBgWindow, 683 + 'https://example.com' 684 + ); 685 + 686 + await waitForPageReady(pageWindow); 687 + 688 + // Record original bounds 689 + const originalBounds = await getWindowBounds(pageWindow); 690 + 691 + // Maximize 692 + await sharedBgWindow.evaluate(async (wid: number) => { 693 + (window as any).app.publish( 694 + 'page:maximize', 695 + { windowId: wid }, 696 + (window as any).app.scopes.GLOBAL 697 + ); 698 + }, windowId); 699 + 700 + await pageWindow.waitForFunction( 701 + () => document.body.classList.contains('maximized'), 702 + undefined, 703 + { timeout: 5000 } 704 + ); 705 + 706 + // Show navbar, then drag it to exit maximized state 707 + await sharedBgWindow.evaluate(async (wid: number) => { 708 + (window as any).app.publish( 709 + 'page:show-navbar', 710 + { windowId: wid }, 711 + (window as any).app.scopes.GLOBAL 712 + ); 713 + }, windowId); 714 + 715 + await pageWindow.waitForFunction( 716 + () => { 717 + const navbar = document.getElementById('navbar'); 718 + return navbar && navbar.classList.contains('visible'); 719 + }, 720 + undefined, 721 + { timeout: 5000 } 722 + ); 723 + 724 + // Simulate drag on navbar 725 + await pageWindow.evaluate(() => { 726 + const navbar = document.getElementById('navbar') as HTMLElement; 727 + navbar.dispatchEvent(new MouseEvent('mousedown', { 728 + bubbles: true, 729 + screenX: 400, 730 + screenY: 20, 731 + button: 0, 732 + })); 733 + document.dispatchEvent(new MouseEvent('mousemove', { 734 + bubbles: true, 735 + screenX: 450, 736 + screenY: 50, 737 + })); 738 + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); 739 + }); 740 + 741 + // Should exit maximized state 742 + await pageWindow.waitForFunction( 743 + () => !document.body.classList.contains('maximized'), 744 + undefined, 745 + { timeout: 5000 } 746 + ); 747 + 748 + // Restored window should have the original dimensions (not work area dimensions) 749 + const restoredBounds = await getWindowBounds(pageWindow); 750 + expect(restoredBounds.width).toBe(originalBounds.width); 751 + expect(restoredBounds.height).toBe(originalBounds.height); 752 + 753 + await closeWindow(sharedBgWindow, windowId); 754 + }); 755 + 756 + // Test 13: Webview border-radius removed when maximized 757 + // Will pass after maximize implementation 758 + test.skip('webview border-radius removed when maximized', async () => { 759 + const { pageWindow, windowId } = await openCanvasPage( 760 + sharedBgWindow, 761 + 'https://example.com' 762 + ); 763 + 764 + await waitForPageReady(pageWindow); 765 + 766 + // Verify non-maximized has border-radius 767 + const normalRadius = await pageWindow.evaluate(() => { 768 + const webview = document.getElementById('content') as HTMLElement; 769 + return window.getComputedStyle(webview).borderRadius; 770 + }); 771 + expect(normalRadius).not.toBe('0px'); 772 + 773 + // Maximize 774 + await sharedBgWindow.evaluate(async (wid: number) => { 775 + (window as any).app.publish( 776 + 'page:maximize', 777 + { windowId: wid }, 778 + (window as any).app.scopes.GLOBAL 779 + ); 780 + }, windowId); 781 + 782 + await pageWindow.waitForFunction( 783 + () => document.body.classList.contains('maximized'), 784 + undefined, 785 + { timeout: 5000 } 786 + ); 787 + 788 + // Border-radius should be removed 789 + const maximizedRadius = await pageWindow.evaluate(() => { 790 + const webview = document.getElementById('content') as HTMLElement; 791 + return window.getComputedStyle(webview).borderRadius; 792 + }); 793 + expect(maximizedRadius).toBe('0px'); 794 + 795 + await closeWindow(sharedBgWindow, windowId); 796 + }); 797 + 798 + // Test 14: Double-click navbar maximizes 799 + // Will pass after maximize implementation 800 + test.skip('double-click navbar triggers maximize', async () => { 801 + const { pageWindow, windowId } = await openCanvasPage( 802 + sharedBgWindow, 803 + 'https://example.com' 804 + ); 805 + 806 + await waitForPageReady(pageWindow); 807 + 808 + // Show navbar 809 + await sharedBgWindow.evaluate(async (wid: number) => { 810 + (window as any).app.publish( 811 + 'page:show-navbar', 812 + { windowId: wid }, 813 + (window as any).app.scopes.GLOBAL 814 + ); 815 + }, windowId); 816 + 817 + await pageWindow.waitForFunction( 818 + () => { 819 + const navbar = document.getElementById('navbar'); 820 + return navbar && navbar.classList.contains('visible'); 821 + }, 822 + undefined, 823 + { timeout: 5000 } 824 + ); 825 + 826 + // Double-click on navbar 827 + await pageWindow.evaluate(() => { 828 + const navbar = document.getElementById('navbar') as HTMLElement; 829 + navbar.dispatchEvent(new MouseEvent('dblclick', { 830 + bubbles: true, 831 + screenX: 400, 832 + screenY: 20, 833 + })); 834 + }); 835 + 836 + // Should enter maximized state 837 + await pageWindow.waitForFunction( 838 + () => document.body.classList.contains('maximized'), 839 + undefined, 840 + { timeout: 5000 } 841 + ); 842 + 843 + await closeWindow(sharedBgWindow, windowId); 844 + }); 845 + });