experiments in a post-browser web
10
fork

Configure Feed

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

feat(pagestream): global card navigation while page host is open

When a page is opened from pagestream, Cmd+J/K and Cmd+Up/Down navigate
between cards globally (even when page host has focus). The open page
navigates to the new card's URL via page:navigate pubsub message.

Adds page:navigate subscriber to page host. Refactors keyboard handling
to share moveSelection() between local keydown and global shortcuts.

Also adds docs/extension-modes.md research note for future formalized
extension mode API.

+177 -21
+11
app/page/page.js
··· 847 847 } 848 848 }, api.scopes.GLOBAL); 849 849 850 + // Navigate to a new URL (used by pagestream to flip between cards) 851 + api.subscribe('page:navigate', (msg) => { 852 + if (msg.windowId != null && msg.windowId !== myWindowId) return; 853 + const url = msg.url; 854 + if (url) { 855 + DEBUG && console.log('[page] page:navigate:', url); 856 + webview.loadURL(url); 857 + navbar.setUrl(url); 858 + } 859 + }, api.scopes.GLOBAL); 860 + 850 861 // --- Nav button actions and URL navigation are handled by peek-navbar component --- 851 862 // Event listeners wired up in the "peek-navbar component event wiring" section above. 852 863 // On navigate or dismiss (Escape), also hide the navbar (page-specific behavior).
+88
docs/extension-modes.md
··· 1 + # Extension Modes — Research Note 2 + 3 + ## Problem 4 + 5 + Extensions sometimes need global keyboard shortcuts that are only active during a specific interaction context. For example: 6 + 7 + - **Pagestream**: When a page host window is open from the card stream, Cmd+J/K/Up/Down should navigate between cards and change the page being viewed — without the user switching back to the pagestream window. 8 + - **Groups**: When viewing a group's pages, similar navigation could flip between pages within the group. 9 + 10 + Currently, each extension manages this ad-hoc: registering global shortcuts when entering the interaction, unregistering when leaving. This works but has issues: 11 + 12 + 1. **No collision detection** — two extensions could register the same global shortcut 13 + 2. **No central visibility** — nothing tracks which "mode" is active or what shortcuts are bound 14 + 3. **Cleanup risk** — if an extension crashes or fails to unregister, shortcuts leak 15 + 4. **No composability** — modes can't layer (e.g., pagestream browsing + tag filtering) 16 + 17 + ## Current Implementation (Pagestream, Feb 2026) 18 + 19 + Pagestream uses the simple ad-hoc approach: 20 + 21 + ```javascript 22 + // On page host open: 23 + api.shortcuts.register('CommandOrControl+J', () => moveSelection('down'), { global: true }); 24 + // ... more shortcuts 25 + 26 + // On page host close: 27 + api.shortcuts.unregister('CommandOrControl+J', { global: true }); 28 + // ... more shortcuts 29 + ``` 30 + 31 + Cross-window communication uses pubsub: 32 + ```javascript 33 + // Pagestream publishes: 34 + api.publish('page:navigate', { windowId, url }, api.scopes.GLOBAL); 35 + 36 + // Page host subscribes: 37 + api.subscribe('page:navigate', (msg) => { 38 + if (msg.windowId !== myWindowId) return; 39 + webview.loadURL(msg.url); 40 + }); 41 + ``` 42 + 43 + This is fine for one extension. Problems emerge with multiple. 44 + 45 + ## Proposed: Extension Mode API 46 + 47 + ### Concept 48 + 49 + An extension declares a **mode** — a named interaction context with associated global shortcuts. The system manages activation, deactivation, collision detection, and cleanup. 50 + 51 + ```javascript 52 + // Extension declares a mode 53 + const browsingMode = api.mode.create('pagestream:browsing', { 54 + shortcuts: { 55 + 'CommandOrControl+J': () => moveSelection('down'), 56 + 'CommandOrControl+K': () => moveSelection('up'), 57 + 'CommandOrControl+Up': () => moveSelection('up'), 58 + 'CommandOrControl+Down': () => moveSelection('down'), 59 + 'CommandOrControl+Shift+G': () => moveSelection('first'), 60 + } 61 + }); 62 + 63 + // Enter/exit mode 64 + browsingMode.enter(); // registers all shortcuts 65 + browsingMode.exit(); // unregisters all shortcuts 66 + 67 + // Auto-cleanup if extension window closes 68 + // System tracks active modes and cleans up on window:closed 69 + ``` 70 + 71 + ### Benefits 72 + 73 + - **Collision detection**: System warns or rejects if two modes claim the same shortcut 74 + - **Automatic cleanup**: Mode exits when its owning window closes 75 + - **Visibility**: `api.mode.active()` returns current mode stack — useful for UI indicators 76 + - **Composability**: Modes could stack (enter multiple modes, exit in order) 77 + - **Discoverability**: Users could see "Pagestream browsing mode active: Cmd+J/K to navigate" 78 + 79 + ### Open Questions 80 + 81 + 1. **Should modes be exclusive or stackable?** Exclusive is simpler but less flexible. 82 + 2. **Should the mode indicator be system-level UI?** A subtle badge showing "Browsing Mode" etc. 83 + 3. **Should modes affect non-shortcut behavior?** E.g., change what Escape does globally. 84 + 4. **Where does IZUI fit?** IZUI manages invocation context (transient vs active). Modes manage interaction context within an already-active extension. They're orthogonal but could interact — e.g., a transient invocation might skip mode activation. 85 + 86 + ### When to Build 87 + 88 + Build this when a second extension needs the pattern. Groups is the likely candidate — navigating between group pages while one is open would use the exact same register/navigate/unregister flow. At that point, extract the pattern from pagestream into the extension API.
+78 -21
extensions/pagestream/home.js
··· 378 378 }); 379 379 if (result && result.id) { 380 380 state.openWindowId = result.id; 381 + registerGlobalNav(); 381 382 } 382 383 } catch (err) { 383 384 console.error('[pagestream] Failed to open page:', err); ··· 391 392 }, 300); 392 393 }; 393 394 395 + // ===== Global navigation while page host is open ===== 396 + 397 + let globalNavRegistered = false; 398 + 399 + /** Move card selection and optionally navigate the open page host */ 400 + const moveSelection = (action) => { 401 + const cards = getCards(); 402 + if (cards.length === 0) return; 403 + 404 + const prevIndex = state.selectedIndex; 405 + 406 + switch (action) { 407 + case 'down': 408 + if (state.selectedIndex < cards.length - 1) state.selectedIndex++; 409 + break; 410 + case 'up': 411 + if (state.selectedIndex > 0) state.selectedIndex--; 412 + break; 413 + case 'first': 414 + state.selectedIndex = 0; 415 + break; 416 + case 'last': 417 + state.selectedIndex = cards.length - 1; 418 + break; 419 + } 420 + 421 + if (state.selectedIndex === prevIndex) return; 422 + 423 + updateSelection(); 424 + 425 + // If a page host is open, navigate it to the new card's URL 426 + if (state.openWindowId) { 427 + const filtered = getFilteredVisits(); 428 + const entry = filtered[state.selectedIndex]; 429 + if (entry) { 430 + state.openCardIndex = state.selectedIndex; 431 + api.publish('page:navigate', { 432 + windowId: state.openWindowId, 433 + url: entry.item.content 434 + }, api.scopes.GLOBAL); 435 + } 436 + } 437 + }; 438 + 439 + const GLOBAL_NAV_SHORTCUTS = [ 440 + ['CommandOrControl+Down', () => moveSelection('down')], 441 + ['CommandOrControl+Up', () => moveSelection('up')], 442 + ['CommandOrControl+J', () => moveSelection('down')], 443 + ['CommandOrControl+K', () => moveSelection('up')], 444 + ['CommandOrControl+Shift+G', () => moveSelection('first')], 445 + ['CommandOrControl+Shift+End', () => moveSelection('last')], 446 + ]; 447 + 448 + const registerGlobalNav = () => { 449 + if (globalNavRegistered) return; 450 + for (const [key, fn] of GLOBAL_NAV_SHORTCUTS) { 451 + api.shortcuts.register(key, fn, { global: true }); 452 + } 453 + globalNavRegistered = true; 454 + debug && console.log('[pagestream] Global nav shortcuts registered'); 455 + }; 456 + 457 + const unregisterGlobalNav = () => { 458 + if (!globalNavRegistered) return; 459 + for (const [key] of GLOBAL_NAV_SHORTCUTS) { 460 + api.shortcuts.unregister(key, { global: true }); 461 + } 462 + globalNavRegistered = false; 463 + debug && console.log('[pagestream] Global nav shortcuts unregistered'); 464 + }; 465 + 394 466 const onPageHostClosed = async (closedWindowId) => { 395 467 if (closedWindowId !== state.openWindowId) return; 468 + unregisterGlobalNav(); 396 469 state.openWindowId = null; 397 470 398 471 const cards = getCards(); ··· 568 641 case 'j': 569 642 case 'ArrowDown': 570 643 e.preventDefault(); 571 - if (state.selectedIndex < cards.length - 1) { 572 - state.selectedIndex++; 573 - updateSelection(); 574 - } 644 + moveSelection('down'); 575 645 break; 576 646 case 'k': 577 647 case 'ArrowUp': 578 648 e.preventDefault(); 579 - if (state.selectedIndex > 0) { 580 - state.selectedIndex--; 581 - updateSelection(); 582 - } 649 + moveSelection('up'); 583 650 break; 584 651 case 'Enter': 585 652 e.preventDefault(); ··· 592 659 } 593 660 break; 594 661 case 'g': 595 - e.preventDefault(); 596 - state.selectedIndex = 0; 597 - updateSelection(); 598 - break; 599 - case 'G': 600 - e.preventDefault(); 601 - state.selectedIndex = cards.length - 1; 602 - updateSelection(); 603 - break; 604 662 case 'Home': 605 663 e.preventDefault(); 606 - state.selectedIndex = 0; 607 - updateSelection(); 664 + moveSelection('first'); 608 665 break; 666 + case 'G': 609 667 case 'End': 610 668 e.preventDefault(); 611 - state.selectedIndex = cards.length - 1; 612 - updateSelection(); 669 + moveSelection('last'); 613 670 break; 614 671 } 615 672 };