experiments in a post-browser web
10
fork

Configure Feed

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

feat(izui): complete role-based migration (Phase 3-6)

Phase 3: All extension openers declare role explicitly
Phase 4: One-way session ratchet (transient→active, never back)
Phase 5: Remove izui-set-escape-mode self-declaration anti-pattern
Phase 6: Delete app/izui.js, all consumers use api.escape.onEscape() directly

+92 -204
+1 -3
app/datastore/viewer.js
··· 1 1 // Datastore Viewer 2 2 import api from '../api.js'; 3 - import izui from '../izui.js'; 4 3 5 4 console.log('datastore viewer loading'); 6 5 7 - // Initialize IZUI - this window is a child of settings, notify parent on close 8 - izui.init({ canHaveChildren: false }); 6 + // No IZUI init needed — backend handles parent tracking and focus restoration via roles. 9 7 10 8 const tables = ['addresses', 'visits', 'content', 'tags', 'blobs', 'scripts_data', 'feeds']; 11 9
+1 -4
app/diagnostic.html
··· 38 38 <div id="output"></div> 39 39 40 40 <script type="module"> 41 - import izui from './izui.js'; 42 - 43 41 const api = window.app; 44 42 const output = document.getElementById('output'); 45 43 46 - // Initialize IZUI - notify parent on close 47 - izui.init({ canHaveChildren: false }); 44 + // No IZUI init needed — backend handles parent tracking and focus restoration via roles. 48 45 49 46 function log(title, content) { 50 47 const section = document.createElement('div');
+2
app/index.js
··· 249 249 description: 'Open Datastore viewer', 250 250 execute: async () => { 251 251 await api.window.open('peek://app/datastore/viewer.html', { 252 + role: 'utility', 252 253 width: 900, 253 254 height: 600, 254 255 key: 'datastore-viewer' ··· 261 262 description: 'Open Diagnostic tool', 262 263 execute: async () => { 263 264 await api.window.open('peek://app/diagnostic.html', { 265 + role: 'utility', 264 266 width: 900, 265 267 height: 700, 266 268 key: 'diagnostic-tool'
-102
app/izui.js
··· 1 - /** 2 - * IZUI: Inverted Zooming User Interface - Thin Backward-Compatibility Wrapper 3 - * 4 - * The backend now handles all IZUI policy: 5 - * - Parent centering (centerOnParent in window-open handler) 6 - * - Parent tracking (parentWindowId auto-set from ev.sender) 7 - * - Focus restoration (parent focused on child close) 8 - * - ESC policy for children vs root windows 9 - * 10 - * This file exists only for backward compatibility. New code should use 11 - * api.window.open() and api.escape.onEscape() directly. 12 - * 13 - * See notes/izui-model.md for the full design. 14 - */ 15 - 16 - import api from './api.js'; 17 - 18 - const DEBUG = true; 19 - 20 - /** 21 - * Open a child window. 22 - * Delegates to api.window.open() - the backend handles centering and parent tracking. 23 - * 24 - * @deprecated Use api.window.open() directly. Backend auto-centers on parent and tracks parentWindowId. 25 - * @param {string} address - URL to open 26 - * @param {Object} params - Window parameters 27 - * @returns {Promise<Object>} Window controller with { id, ... } 28 - */ 29 - export async function openChildWindow(address, params = {}) { 30 - DEBUG && console.log('[IZUI] openChildWindow (delegating to api.window.open):', address); 31 - return api.window.open(address, params); 32 - } 33 - 34 - /** 35 - * Notify parent window to take focus. 36 - * @deprecated Backend now focuses parent automatically on child close. 37 - */ 38 - export function notifyParentFocus() { 39 - DEBUG && console.log('[IZUI] notifyParentFocus (no-op, backend handles focus restoration)'); 40 - } 41 - 42 - /** 43 - * Set up focus listener. 44 - * @deprecated Backend now focuses parent automatically on child close. 45 - */ 46 - export function setupFocusListener() { 47 - DEBUG && console.log('[IZUI] setupFocusListener (no-op, backend handles focus restoration)'); 48 - } 49 - 50 - /** 51 - * Set up escape handler. 52 - * Delegates to api.escape.onEscape(). 53 - * 54 - * @deprecated Use api.escape.onEscape() directly. 55 - * @param {Function} [internalHandler] - Handler for internal navigation 56 - * @param {Object} [options] - Options (ignored, backend handles close policy) 57 - * @returns {Function} Cleanup function 58 - */ 59 - export function setupEscapeHandler(internalHandler, options = {}) { 60 - DEBUG && console.log('[IZUI] setupEscapeHandler (delegating to api.escape.onEscape)'); 61 - 62 - if (api.escape && api.escape.onEscape) { 63 - api.escape.onEscape(internalHandler); 64 - } 65 - 66 - return () => { 67 - // Cleanup - currently no way to unregister escape handler 68 - }; 69 - } 70 - 71 - /** 72 - * Initialize IZUI for a window. 73 - * Thin wrapper that sets up escape handling via api.escape.onEscape(). 74 - * 75 - * @deprecated Use api.escape.onEscape() directly instead of izui.init(). 76 - * @param {Object} options 77 - * @param {Function} [options.onEscape] - Internal escape handler 78 - * @param {boolean} [options.canHaveChildren] - Ignored (backend tracks parents) 79 - * @param {boolean} [options.setupEscape=true] - Whether to set up escape handling 80 - * @param {boolean} [options.closeOnEscape] - Ignored (backend handles close policy) 81 - * @param {number} [options.parentWindowId] - Ignored (backend auto-tracks parent) 82 - */ 83 - export async function init(options = {}) { 84 - const { onEscape, setupEscape = true } = options; 85 - 86 - DEBUG && console.log('[IZUI] init (thin wrapper, backend handles policy)'); 87 - 88 - // Only set up escape handler if requested and handler provided 89 - if (setupEscape && onEscape) { 90 - setupEscapeHandler(onEscape); 91 - } 92 - 93 - DEBUG && console.log('[IZUI] Initialized for:', window.location.toString()); 94 - } 95 - 96 - export default { 97 - init, 98 - setupFocusListener, 99 - setupEscapeHandler, 100 - openChildWindow, 101 - notifyParentFocus 102 - };
+11 -17
app/page/page.js
··· 8 8 * - Resize via IPC to resize the BrowserWindow 9 9 */ 10 10 11 - import izui from '../izui.js'; 12 11 import api from '../api.js'; 13 12 14 13 const DEBUG = true; ··· 160 159 sel.addRange(range); 161 160 }); 162 161 163 - // --- IZUI integration --- 162 + // --- Escape handling --- 164 163 165 - // Initialize IZUI for proper stack navigation 166 - // Web page containers can have children (links opening new windows) 167 - izui.init({ 168 - canHaveChildren: true, 169 - onEscape: () => { 170 - if (webview.canGoBack()) { 171 - DEBUG && console.log('[page] ESC: webview going back'); 172 - webview.goBack(); 173 - return { handled: true }; 174 - } 175 - DEBUG && console.log('[page] ESC: at root, closing'); 176 - return { handled: false }; 164 + // Register escape handler for internal navigation (back in webview history) 165 + // The backend determines close policy based on the window's role. 166 + api.escape.onEscape(() => { 167 + if (webview.canGoBack()) { 168 + DEBUG && console.log('[page] ESC: webview going back'); 169 + webview.goBack(); 170 + return { handled: true }; 177 171 } 178 - }).catch(err => { 179 - console.error('[page] IZUI init error:', err); 172 + DEBUG && console.log('[page] ESC: at root, letting backend decide'); 173 + return { handled: false }; 180 174 }); 181 175 182 176 // --- Webview events --- ··· 210 204 webview.addEventListener('new-window', async (e) => { 211 205 DEBUG && console.log('[page] new-window:', e.url); 212 206 e.preventDefault(); 213 - await izui.openChildWindow(e.url, { width: 1024, height: 768 }); 207 + await api.window.open(e.url, { role: 'child-content', width: 1024, height: 768 }); 214 208 }); 215 209 216 210 webview.addEventListener('did-fail-load', (e) => {
+5 -6
app/settings/settings.js
··· 2 2 import { createDatastoreStore } from '../utils.js'; 3 3 import api from '../api.js'; 4 4 import fc from '../features.js'; 5 - import izui from '../izui.js'; 6 5 7 6 const DEBUG = api.debug; 8 7 const clear = false; ··· 2465 2464 datastoreNav.textContent = 'Datastore'; 2466 2465 datastoreNav.style.cursor = 'pointer'; 2467 2466 datastoreNav.addEventListener('click', () => { 2468 - izui.openChildWindow('peek://app/datastore/viewer.html', { 2467 + api.window.open('peek://app/datastore/viewer.html', { 2468 + role: 'utility', 2469 2469 width: 900, 2470 2470 height: 600, 2471 2471 key: 'datastore-viewer' ··· 2479 2479 diagnosticNav.textContent = 'Diagnostic'; 2480 2480 diagnosticNav.style.cursor = 'pointer'; 2481 2481 diagnosticNav.addEventListener('click', () => { 2482 - izui.openChildWindow('peek://app/diagnostic.html', { 2482 + api.window.open('peek://app/diagnostic.html', { 2483 + role: 'utility', 2483 2484 width: 900, 2484 2485 height: 700, 2485 2486 key: 'diagnostic-tool' ··· 2515 2516 2516 2517 window.addEventListener('load', init); 2517 2518 2518 - // Initialize IZUI for focus restoration when child windows close 2519 - // Settings is a parent window, so ESC should not close it 2520 - izui.init({ canHaveChildren: true, closeOnEscape: false }); 2519 + // No IZUI init needed — backend handles focus restoration and ESC policy via roles. 2521 2520 2522 2521 window.addEventListener('blur', () => { 2523 2522 console.log('core settings blur');
+5 -28
backend/electron/ipc.ts
··· 1678 1678 if (existingWindow) { 1679 1679 DEBUG && console.log('Reusing existing window with key:', options.key); 1680 1680 1681 - // RE-EVALUATE transient state on show, not just creation 1682 - // This fixes keepLive windows (like cmd bar) being stuck with stale transient state 1681 + // For keepLive reuse, update the per-window transient flag from current session state 1682 + // but do NOT call evaluateOnShow() — session state shouldn't change just because 1683 + // a hidden window was re-shown (Phase 4: session entryMode is stable) 1683 1684 const coordinator = getIzuiCoordinator(); 1684 - const newTransientState = coordinator.evaluateOnShow(); 1685 1685 const existingData = existingWindow.data as { params: Record<string, unknown> }; 1686 - existingData.params.transient = (newTransientState === 'transient'); 1687 - // Preserve IZUI-relevant params from the new open request 1688 - // escapeMode may differ between invocations (e.g., first open vs reuse) 1689 - if (options.escapeMode) { 1690 - existingData.params.escapeMode = options.escapeMode; 1691 - } 1692 - DEBUG && console.log('Reused window transient re-evaluated:', newTransientState, 'escapeMode:', existingData.params.escapeMode); 1686 + existingData.params.transient = coordinator.isTransient(); 1687 + DEBUG && console.log('Reused window transient from session:', existingData.params.transient); 1693 1688 1694 1689 if (!isHeadless()) { 1695 1690 existingWindow.window.show(); ··· 2657 2652 ipcMain.handle('izui-get-state', () => { 2658 2653 const coordinator = getIzuiCoordinator(); 2659 2654 return coordinator.getState(); 2660 - }); 2661 - 2662 - // IZUI: renderer self-declares its escape mode 2663 - // Called when a window registers an escape handler via api.escape.onEscape() 2664 - // This ensures the backend respects renderer-owned lifecycle even if the 2665 - // opener's escapeMode param was lost (e.g., consolidated iframe IPC) 2666 - ipcMain.handle('izui-set-escape-mode', (ev, mode: string) => { 2667 - const win = BrowserWindow.fromWebContents(ev.sender); 2668 - if (win && !win.isDestroyed()) { 2669 - const entry = getWindowInfo(win.id); 2670 - if (entry) { 2671 - const oldMode = entry.params.escapeMode; 2672 - entry.params.escapeMode = mode; 2673 - console.log(`[izui] Window ${win.id} self-declared escapeMode: ${oldMode} -> ${mode}`); 2674 - return { success: true }; 2675 - } 2676 - } 2677 - return { success: false, error: 'Window not found' }; 2678 2655 }); 2679 2656 2680 2657 // IZUI: renderer requests its own window to close/hide (navigate mode)
+12 -9
backend/electron/izui-state.test.ts
··· 95 95 assert.strictEqual(result, 'active'); 96 96 }); 97 97 98 - it('should update session.entryMode when focus changes', () => { 98 + it('should implement one-way ratchet: transient -> active, never active -> transient', () => { 99 99 const coordinator = izuiState.getIzuiCoordinator(); 100 100 101 - // Start active (app focused) 101 + // Start transient (app not focused) 102 + coordinator.setAppFocused(false); 103 + coordinator.evaluateOnShow(); 104 + assert.strictEqual(coordinator.getSession()!.entryMode, 'transient'); 105 + 106 + // User focuses Peek (app gains focus) 107 + coordinator.setAppFocused(true); 102 108 coordinator.evaluateOnShow(); 109 + // Should promote from transient to active 103 110 assert.strictEqual(coordinator.getSession()!.entryMode, 'active'); 104 111 105 112 // User switches to another app 106 113 coordinator.setAppFocused(false); 107 114 coordinator.evaluateOnShow(); 108 - assert.strictEqual(coordinator.getSession()!.entryMode, 'transient'); 109 - 110 - // User switches back to Peek 111 - coordinator.setAppFocused(true); 112 - coordinator.evaluateOnShow(); 115 + // Should NOT downgrade from active to transient (one-way ratchet) 113 116 assert.strictEqual(coordinator.getSession()!.entryMode, 'active'); 114 117 }); 115 118 ··· 122 125 assert.ok(coordinator.getSession(), 'Session should be created'); 123 126 }); 124 127 125 - it('should update existing session entry mode without creating new session', () => { 128 + it('should not downgrade active session to transient (one-way ratchet)', () => { 126 129 const coordinator = izuiState.getIzuiCoordinator(); 127 130 coordinator.startSession('active'); 128 131 const originalSessionId = coordinator.getSession()!.id; ··· 133 136 134 137 const session = coordinator.getSession(); 135 138 assert.strictEqual(session!.id, originalSessionId, 'Should be same session'); 136 - assert.strictEqual(session!.entryMode, 'transient', 'Entry mode should be updated'); 139 + assert.strictEqual(session!.entryMode, 'active', 'Entry mode should NOT be downgraded'); 137 140 }); 138 141 }); 139 142
+9 -4
backend/electron/izui-state.ts
··· 187 187 if (!this.session) { 188 188 this.startSession(result); 189 189 } else { 190 - // Update entry mode for this invocation (e.g., keepLive window reuse) 191 - this.session.entryMode = result; 192 - this.state = result; 193 - DEBUG && console.log('[izui] Updated session entryMode to:', result); 190 + // One-way ratchet: transient -> active is allowed, active -> transient is not. 191 + // Once a session becomes active (user is working in Peek), it stays active. 192 + if (this.session.entryMode === 'transient' && result === 'active') { 193 + this.session.entryMode = 'active'; 194 + this.state = 'active'; 195 + DEBUG && console.log('[izui] Promoted session from transient to active'); 196 + } else { 197 + DEBUG && console.log('[izui] Session entryMode unchanged:', this.session.entryMode, '(evaluated:', result, ')'); 198 + } 194 199 } 195 200 196 201 return result;
+4
extensions/cmd/background.js
··· 261 261 const width = prefs.width || 600; 262 262 263 263 const params = { 264 + // IZUI role 265 + role: 'palette', 266 + 264 267 debug: log.debug, 265 268 key: panelAddress, 266 269 height: initialHeight, ··· 466 469 const downloadPageUrl = `peek://ext/cmd/download.html?id=${downloadId}`; 467 470 468 471 await api.window.open(downloadPageUrl, { 472 + role: 'utility', 469 473 width: 400, 470 474 height: 200, 471 475 show: true,
+1
extensions/cmd/panel.js
··· 491 491 492 492 // Open URL directly using api.window.open 493 493 api.window.open(urlResult.url, { 494 + role: 'content', 494 495 width: 800, 495 496 height: 600, 496 497 trackingSource: 'cmd',
+1
extensions/editor/background.js
··· 24 24 if (qs) url += '?' + qs; 25 25 } 26 26 api.window.open(url, { 27 + role: 'workspace', 27 28 key: 'editor-home', 28 29 width: 1200, 29 30 height: 800,
+4 -1
extensions/groups/background.js
··· 59 59 const width = 800; 60 60 61 61 const params = { 62 + // IZUI role 63 + role: 'workspace', 64 + 62 65 key: address, 63 66 height, 64 67 width, 65 - escapeMode: 'navigate', 66 68 trackingSource: 'cmd', 67 69 trackingSourceId: 'groups' 68 70 }; ··· 244 246 const openedWindows = []; 245 247 for (const item of urlItems) { 246 248 const result = await api.window.open(item._openUrl, { 249 + role: 'content', 247 250 trackingSource: 'cmd', 248 251 trackingSourceId: `group:${groupName}`, 249 252 // Pass group context for mode inheritance
+1
extensions/groups/home.js
··· 624 624 card.addEventListener('card-click', async () => { 625 625 debug && console.log('Opening address:', addressUrl); 626 626 const result = await api.window.open(addressUrl, { 627 + role: 'content', 627 628 width: 800, 628 629 height: 600 629 630 });
+3 -1
extensions/peeks/background.js
··· 58 58 const width = item.width || 800; 59 59 60 60 const params = { 61 + // IZUI role 62 + role: 'quick-view', 63 + 61 64 // browserwindow 62 65 height, 63 66 width, ··· 67 70 type: 'panel', 68 71 69 72 // peek 70 - feature: labels.name, 71 73 keepLive: item.keepLive || false, 72 74 persistState: item.persistState || false, 73 75
+3 -1
extensions/slides/background.js
··· 125 125 126 126 function openNewSlide() { 127 127 const params = { 128 + // IZUI role 129 + role: 'quick-view', 130 + 128 131 address: item.address, 129 132 height, 130 133 width, ··· 134 137 modal: true, 135 138 type: 'panel', 136 139 137 - feature: labels.name, 138 140 keepLive: item.keepLive || false, 139 141 persistState: item.persistState || false, 140 142
+1
extensions/tags/background.js
··· 19 19 function openTags() { 20 20 if (hasPeekAPI) { 21 21 api.window.open('peek://ext/tags/home.html', { 22 + role: 'workspace', 22 23 key: 'tags-home', 23 24 width: 900, 24 25 height: 700,
+1
extensions/tags/home.js
··· 41 41 const openItemUrl = async (url) => { 42 42 try { 43 43 await api.window.open(url, { 44 + role: 'content', 44 45 width: 800, 45 46 height: 600, 46 47 trackingSource: 'tags',
+3
extensions/windows/background.js
··· 39 39 */ 40 40 const openWindowsView = async () => { 41 41 const params = { 42 + // IZUI role 43 + role: 'overlay', 44 + 42 45 key: address, 43 46 transparent: true, 44 47 alwaysOnTop: true,
+3 -4
preload.js
··· 1605 1605 */ 1606 1606 onEscape: (callback) => { 1607 1607 _escapeCallback = callback; 1608 - // Self-declare: tell backend this window has an escape handler 1609 - // This ensures escapeMode='navigate' even if the opener didn't set it 1610 - // (e.g., consolidated iframe extensions where params may be lost in IPC) 1611 - ipcRenderer.invoke('izui-set-escape-mode', 'navigate'); 1608 + // The callback is purely informational — the backend consults it via 1609 + // escape-pressed IPC and decides close/nothing based on the window's 1610 + // declared role (set at open time), not self-declaration. 1612 1611 }, 1613 1612 /** 1614 1613 * Trigger the escape handler directly (for testing)
+21 -24
tests/desktop/smoke.spec.ts
··· 3640 3640 } 3641 3641 }); 3642 3642 3643 - test('izui-set-escape-mode updates window escape mode via onEscape', async () => { 3643 + test('onEscape registers callback without changing backend escapeMode (role-based)', async () => { 3644 3644 const bgWindow = sharedBgWindow; 3645 3645 3646 3646 // Open a plain window with no escapeMode set (defaults to 'auto') ··· 3656 3656 const contentWindow = await sharedApp.getWindow('about:blank', 5000); 3657 3657 expect(contentWindow).toBeTruthy(); 3658 3658 3659 - // Verify the window starts without 'navigate' escape mode 3659 + // Get escapeMode before registering handler 3660 3660 const infoBefore = await bgWindow.evaluate(async (wid: number) => { 3661 3661 const listResult = await (window as any).app.window.list({ includeInternal: true }); 3662 3662 if (!listResult.success) return null; 3663 3663 return listResult.windows.find((w: any) => w.id === wid); 3664 3664 }, windowId); 3665 3665 expect(infoBefore).toBeTruthy(); 3666 - // Should be undefined or 'auto', not 'navigate' 3667 - expect(infoBefore.params.escapeMode).not.toBe('navigate'); 3666 + const escapeModeBefore = infoBefore.params.escapeMode; 3668 3667 3669 - // Call api.escape.onEscape() from the content window, which internally 3670 - // invokes ipcRenderer.invoke('izui-set-escape-mode', 'navigate') 3668 + // Call api.escape.onEscape() — this should NOT change escapeMode on the backend 3669 + // (self-declaration removed; role determines behavior now) 3671 3670 await contentWindow.evaluate(() => { 3672 3671 (window as any).app.escape.onEscape(() => ({ handled: false })); 3673 3672 }); 3674 3673 3675 - // Poll for the escape mode to be updated (the IPC is async) 3676 - const windowInfo = await bgWindow.evaluate(async (wid: number) => { 3677 - const start = Date.now(); 3678 - while (Date.now() - start < 3000) { 3679 - const listResult = await (window as any).app.window.list({ includeInternal: true }); 3680 - if (listResult.success) { 3681 - const win = listResult.windows.find((w: any) => w.id === wid); 3682 - if (win && win.params.escapeMode === 'navigate') { 3683 - return win; 3684 - } 3685 - } 3686 - await new Promise(r => setTimeout(r, 100)); 3687 - } 3688 - // Return the last result for debugging 3689 - const finalResult = await (window as any).app.window.list({ includeInternal: true }); 3690 - return finalResult.success ? finalResult.windows.find((w: any) => w.id === wid) : null; 3674 + // Small delay to ensure any async IPC would have completed 3675 + await new Promise(r => setTimeout(r, 300)); 3676 + 3677 + // Verify escapeMode was NOT changed by onEscape registration 3678 + const infoAfter = await bgWindow.evaluate(async (wid: number) => { 3679 + const listResult = await (window as any).app.window.list({ includeInternal: true }); 3680 + if (!listResult.success) return null; 3681 + return listResult.windows.find((w: any) => w.id === wid); 3691 3682 }, windowId); 3692 - expect(windowInfo).toBeTruthy(); 3693 - expect(windowInfo.params.escapeMode).toBe('navigate'); 3683 + expect(infoAfter).toBeTruthy(); 3684 + expect(infoAfter.params.escapeMode).toBe(escapeModeBefore); 3685 + 3686 + // Verify the callback IS registered and responds via escape trigger 3687 + const triggerResult = await contentWindow.evaluate(async () => { 3688 + return await (window as any).app.escape.trigger(); 3689 + }); 3690 + expect(triggerResult).toEqual({ handled: false }); 3694 3691 3695 3692 // Clean up 3696 3693 if (windowId) {