experiments in a post-browser web
10
fork

Configure Feed

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

feat: ESC at root opens Windows switcher via izui policy

+107 -22
+20
app/lib/izui-state.js
··· 390 390 return 'nothing'; 391 391 } 392 392 393 + /** 394 + * ESC unhandled policy: second-phase policy consulted ONLY after the renderer 395 + * returns { handled: false } AND escPolicy() returns 'nothing'. 396 + * 397 + * Determines what to do when ESC has nowhere left to navigate. 398 + * Pure function — no side effects. 399 + * 400 + * @param {string} sessionState - Current IZUI state ('idle'|'transient'|'active'|'overlay') 401 + * @param {string} role - Window role ('quick-view'|'palette'|'utility'|'overlay'|'child-content'|'workspace'|'content') 402 + * @returns {'open-switcher'|'nothing'} 403 + */ 404 + function escUnhandledPolicy(sessionState, role) { 405 + // Only workspace windows in active or idle sessions open the switcher. 406 + // Transient sessions should NOT trigger switcher — user expects ESC to 407 + // return to previous app, not open more UI. 408 + if (role === 'workspace' && (sessionState === 'active' || sessionState === 'idle')) return 'open-switcher'; 409 + return 'nothing'; 410 + } 411 + 393 412 // ============================================================================ 394 413 // Singleton & Exports 395 414 // ============================================================================ ··· 408 427 IzuiStateCoordinator, 409 428 getIzuiCoordinator, 410 429 escPolicy, 430 + escUnhandledPolicy, 411 431 };
+3 -5
app/lib/session.js
··· 29 29 * These are internal bookkeeping or context-dependent values that should be 30 30 * re-derived from the URL and current environment during restore: 31 31 * - x/y/width/height: stale positions from original creation (bounds are authoritative) 32 - * - role: saved role can trigger hasNonContentRole in window-open, preventing 33 - * canvas mode for web pages that had role 'workspace' 34 - * - escapeMode: affects role detection (escapeMode:'navigate' -> role:'workspace') 35 - * which also disables canvas mode for web pages 36 32 * - address: internal bookkeeping param, overwritten by window-open anyway 37 33 * - transient/parentWindowId: session state, not applicable to restored windows 34 + * Note: role and escapeMode are preserved — they're semantic properties that 35 + * describe how the window should behave (e.g., ESC handling for extension windows). 38 36 * @type {Set<string>} 39 37 */ 40 38 export const RESTORE_STRIP_KEYS = new Set([ 41 39 'x', 'y', 'width', 'height', 42 - 'role', 'escapeMode', 'address', 40 + 'address', 43 41 'transient', 'parentWindowId', 44 42 ]); 45 43
+1 -2
backend/electron/ipc.ts
··· 2239 2239 // (webview, navbar, resize handle) are positioned via JS on an invisible surface. 2240 2240 // Non-canvas web pages (modals, quick-views, overlays) load their URL directly. 2241 2241 const isModalOrQuickView = options.modal === true || options.overlay === true; 2242 - const hasNonContentRole = options.role && !['content', 'child-content'].includes(options.role as string); 2243 - const useCanvas = isWebPage && !isModalOrQuickView && !hasNonContentRole; 2242 + const useCanvas = isWebPage && !isModalOrQuickView; 2244 2243 2245 2244 // Use profile-specific session for isolation 2246 2245 const profileSession = getProfileSession();
+46 -1
backend/electron/izui-state.test.ts
··· 55 55 // Import will be done after build 56 56 let izuiState: typeof import('./izui-state.js'); 57 57 58 - // Import escPolicy from the shared module (pure function, no Electron deps) 58 + // Import escPolicy and escUnhandledPolicy from the shared module (pure functions, no Electron deps) 59 59 // At runtime (from dist/backend/electron/) the path is ../../../app/lib/izui-state.js 60 60 let escPolicy: (sessionState: string, role: string) => 'close' | 'close-and-restore' | 'nothing'; 61 + let escUnhandledPolicy: (sessionState: string, role: string) => 'open-switcher' | 'nothing'; 61 62 62 63 describe('IZUI State Coordinator Tests', () => { 63 64 let mockWindows: MockWindow[]; ··· 69 70 // @ts-expect-error — shared JS module in app/lib, no .d.ts in tsconfig rootDir 70 71 const shared = await import('../../../app/lib/izui-state.js'); 71 72 escPolicy = shared.escPolicy; 73 + escUnhandledPolicy = shared.escUnhandledPolicy; 72 74 }); 73 75 74 76 beforeEach(() => { ··· 795 797 assert.strictEqual(escPolicy(coordinator.getState(), 'content'), 'close'); 796 798 assert.strictEqual(escPolicy(coordinator.getState(), 'workspace'), 'nothing', 797 799 'workspace windows must never close on ESC, even in transient sessions'); 800 + }); 801 + }); 802 + 803 + describe('escUnhandledPolicy', () => { 804 + it('should open switcher for workspace in active session', () => { 805 + assert.strictEqual(escUnhandledPolicy('active', 'workspace'), 'open-switcher'); 806 + }); 807 + 808 + it('should open switcher for workspace in idle session', () => { 809 + assert.strictEqual(escUnhandledPolicy('idle', 'workspace'), 'open-switcher'); 810 + }); 811 + 812 + it('should NOT open switcher for workspace in transient session', () => { 813 + assert.strictEqual(escUnhandledPolicy('transient', 'workspace'), 'nothing', 814 + 'transient sessions should not trigger switcher — user expects ESC to return to previous app'); 815 + }); 816 + 817 + it('should NOT open switcher for workspace in overlay session', () => { 818 + assert.strictEqual(escUnhandledPolicy('overlay', 'workspace'), 'nothing'); 819 + }); 820 + 821 + it('should NOT open switcher for content in active session', () => { 822 + assert.strictEqual(escUnhandledPolicy('active', 'content'), 'nothing'); 823 + }); 824 + 825 + it('should NOT open switcher for child-content in active session', () => { 826 + assert.strictEqual(escUnhandledPolicy('active', 'child-content'), 'nothing'); 827 + }); 828 + 829 + it('should NOT open switcher for overlay role in active session', () => { 830 + assert.strictEqual(escUnhandledPolicy('active', 'overlay'), 'nothing'); 831 + }); 832 + 833 + it('should NOT open switcher for palette in active session', () => { 834 + assert.strictEqual(escUnhandledPolicy('active', 'palette'), 'nothing'); 835 + }); 836 + 837 + it('should NOT open switcher for utility in active session', () => { 838 + assert.strictEqual(escUnhandledPolicy('active', 'utility'), 'nothing'); 839 + }); 840 + 841 + it('should NOT open switcher for quick-view in active session', () => { 842 + assert.strictEqual(escUnhandledPolicy('active', 'quick-view'), 'nothing'); 798 843 }); 799 844 }); 800 845 });
+9 -8
backend/electron/session.test.ts
··· 1538 1538 */ 1539 1539 function stripRestoreParams(params: Record<string, unknown>): Record<string, unknown> { 1540 1540 const cleanParams: Record<string, unknown> = {}; 1541 - const stripKeys = new Set(['x', 'y', 'width', 'height', 'role', 'escapeMode', 'address', 'transient', 'parentWindowId']); 1541 + const stripKeys = new Set(['x', 'y', 'width', 'height', 'address', 'transient', 'parentWindowId']); 1542 1542 for (const [key, value] of Object.entries(params)) { 1543 1543 if (!stripKeys.has(key)) { 1544 1544 cleanParams[key] = value; ··· 1563 1563 assert.strictEqual(clean.transparent, true, 'transparent should be preserved'); 1564 1564 }); 1565 1565 1566 - it('strips role to prevent hasNonContentRole disabling canvas mode', () => { 1567 - // BUG: Web page saved with role:'workspace' gets useCanvas=false on restore 1568 - // because hasNonContentRole = true for 'workspace'. 1566 + it('preserves role and escapeMode across session restore', () => { 1567 + // role and escapeMode are semantic properties that affect window behavior 1568 + // (e.g., ESC handling for extension windows like Groups). 1569 1569 const params = { 1570 1570 role: 'workspace', 1571 1571 escapeMode: 'navigate', 1572 1572 key: 'https://example.com', 1573 1573 }; 1574 1574 const clean = stripRestoreParams(params); 1575 - assert.strictEqual(clean.role, undefined, 'role should be stripped'); 1576 - assert.strictEqual(clean.escapeMode, undefined, 'escapeMode should be stripped'); 1575 + assert.strictEqual(clean.role, 'workspace', 'role should be preserved'); 1576 + assert.strictEqual(clean.escapeMode, 'navigate', 'escapeMode should be preserved'); 1577 1577 assert.strictEqual(clean.key, 'https://example.com', 'key should be preserved'); 1578 1578 }); 1579 1579 ··· 1602 1602 trackingSourceId: 'open', 1603 1603 focusable: true, 1604 1604 debug: false, 1605 + role: 'content', 1605 1606 // These should be stripped: 1606 - x: 100, y: 200, role: 'content', address: 'url', transient: false, 1607 + x: 100, y: 200, address: 'url', transient: false, 1607 1608 }; 1608 1609 const clean = stripRestoreParams(params); 1609 1610 assert.strictEqual(clean.key, 'my-window'); ··· 1613 1614 assert.strictEqual(clean.trackingSourceId, 'open'); 1614 1615 assert.strictEqual(clean.focusable, true); 1615 1616 assert.strictEqual(clean.debug, false); 1617 + assert.strictEqual(clean.role, 'content', 'role should be preserved'); 1616 1618 // Verify stripped keys are gone 1617 1619 assert.strictEqual(clean.x, undefined); 1618 1620 assert.strictEqual(clean.y, undefined); 1619 - assert.strictEqual(clean.role, undefined); 1620 1621 assert.strictEqual(clean.address, undefined); 1621 1622 assert.strictEqual(clean.transient, undefined); 1622 1623 });
+3 -5
backend/electron/session.ts
··· 588 588 // These are internal bookkeeping or context-dependent values that should be 589 589 // re-derived from the URL and current environment during restore: 590 590 // - x/y/width/height: stale positions from original creation (bounds are authoritative) 591 - // - role: saved role can trigger hasNonContentRole in window-open, preventing 592 - // canvas mode for web pages that had role 'workspace' (Bug: useCanvas=false) 593 - // - escapeMode: affects role detection (escapeMode:'navigate' -> role:'workspace') 594 - // which also disables canvas mode for web pages 595 591 // - address: internal bookkeeping param, overwritten by window-open anyway 596 592 // - transient/parentWindowId: session state, not applicable to restored windows 593 + // Note: role and escapeMode are preserved — they're semantic properties that 594 + // describe how the window should behave (e.g., ESC handling for extension windows). 597 595 const cleanParams: Record<string, unknown> = {}; 598 - const stripKeys = new Set(['x', 'y', 'width', 'height', 'role', 'escapeMode', 'address', 'transient', 'parentWindowId']); 596 + const stripKeys = new Set(['x', 'y', 'width', 'height', 'address', 'transient', 'parentWindowId']); 599 597 for (const [key, value] of Object.entries(descriptor.params)) { 600 598 if (!stripKeys.has(key)) { 601 599 cleanParams[key] = value;
+25 -1
backend/electron/windows.ts
··· 27 27 getIzuiCoordinator, 28 28 } from './izui-state.js'; 29 29 30 + import { publish, scopes } from './pubsub.js'; 31 + 30 32 /** 31 33 * Get the appropriate background color based on system theme 32 34 * This helps prevent the "white flash" when opening windows in dark mode ··· 121 123 } 122 124 123 125 /** 126 + * ESC unhandled policy: second-phase policy consulted ONLY after the renderer 127 + * returns { handled: false } AND escPolicy() returns 'nothing'. 128 + * 129 + * Determines what to do when ESC has nowhere left to navigate. 130 + * Pure function — no side effects. 131 + */ 132 + export function escUnhandledPolicy(sessionState: string, role: string): 'open-switcher' | 'nothing' { 133 + // Only workspace windows in active or idle sessions open the switcher. 134 + // Transient sessions should NOT trigger switcher — user expects ESC to 135 + // return to previous app, not open more UI. 136 + if (role === 'workspace' && (sessionState === 'active' || sessionState === 'idle')) return 'open-switcher'; 137 + return 'nothing'; 138 + } 139 + 140 + /** 124 141 * Core ESC handling logic for a BrowserWindow. 125 142 * Called when ESC keyDown is detected on the window's own webContents 126 143 * or on a webview guest webContents within the window. ··· 156 173 157 174 if (action === 'close' || action === 'close-and-restore') { 158 175 closeOrHideWindow(bw.id); 176 + } else { 177 + // escPolicy returned 'nothing' — renderer is at root and window won't close. 178 + // Consult second-phase policy for unhandled ESC. 179 + const unhandledAction = escUnhandledPolicy(sessionState, role); 180 + DEBUG && console.log(`[esc] escUnhandledPolicy(${sessionState}, ${role}) -> ${unhandledAction}`); 181 + if (unhandledAction === 'open-switcher') { 182 + publish('peek://system/', scopes.GLOBAL, 'cmd:execute:windows', {}); 183 + } 159 184 } 160 - // 'nothing' -> return silently (active workspace/content at root) 161 185 } 162 186 163 187 /**