···390390 return 'nothing';
391391}
392392393393+/**
394394+ * ESC unhandled policy: second-phase policy consulted ONLY after the renderer
395395+ * returns { handled: false } AND escPolicy() returns 'nothing'.
396396+ *
397397+ * Determines what to do when ESC has nowhere left to navigate.
398398+ * Pure function — no side effects.
399399+ *
400400+ * @param {string} sessionState - Current IZUI state ('idle'|'transient'|'active'|'overlay')
401401+ * @param {string} role - Window role ('quick-view'|'palette'|'utility'|'overlay'|'child-content'|'workspace'|'content')
402402+ * @returns {'open-switcher'|'nothing'}
403403+ */
404404+function escUnhandledPolicy(sessionState, role) {
405405+ // Only workspace windows in active or idle sessions open the switcher.
406406+ // Transient sessions should NOT trigger switcher — user expects ESC to
407407+ // return to previous app, not open more UI.
408408+ if (role === 'workspace' && (sessionState === 'active' || sessionState === 'idle')) return 'open-switcher';
409409+ return 'nothing';
410410+}
411411+393412// ============================================================================
394413// Singleton & Exports
395414// ============================================================================
···408427 IzuiStateCoordinator,
409428 getIzuiCoordinator,
410429 escPolicy,
430430+ escUnhandledPolicy,
411431};
+3-5
app/lib/session.js
···2929 * These are internal bookkeeping or context-dependent values that should be
3030 * re-derived from the URL and current environment during restore:
3131 * - x/y/width/height: stale positions from original creation (bounds are authoritative)
3232- * - role: saved role can trigger hasNonContentRole in window-open, preventing
3333- * canvas mode for web pages that had role 'workspace'
3434- * - escapeMode: affects role detection (escapeMode:'navigate' -> role:'workspace')
3535- * which also disables canvas mode for web pages
3632 * - address: internal bookkeeping param, overwritten by window-open anyway
3733 * - transient/parentWindowId: session state, not applicable to restored windows
3434+ * Note: role and escapeMode are preserved — they're semantic properties that
3535+ * describe how the window should behave (e.g., ESC handling for extension windows).
3836 * @type {Set<string>}
3937 */
4038export const RESTORE_STRIP_KEYS = new Set([
4139 'x', 'y', 'width', 'height',
4242- 'role', 'escapeMode', 'address',
4040+ 'address',
4341 'transient', 'parentWindowId',
4442]);
4543
+1-2
backend/electron/ipc.ts
···22392239 // (webview, navbar, resize handle) are positioned via JS on an invisible surface.
22402240 // Non-canvas web pages (modals, quick-views, overlays) load their URL directly.
22412241 const isModalOrQuickView = options.modal === true || options.overlay === true;
22422242- const hasNonContentRole = options.role && !['content', 'child-content'].includes(options.role as string);
22432243- const useCanvas = isWebPage && !isModalOrQuickView && !hasNonContentRole;
22422242+ const useCanvas = isWebPage && !isModalOrQuickView;
2244224322452244 // Use profile-specific session for isolation
22462245 const profileSession = getProfileSession();
+46-1
backend/electron/izui-state.test.ts
···5555// Import will be done after build
5656let izuiState: typeof import('./izui-state.js');
57575858-// Import escPolicy from the shared module (pure function, no Electron deps)
5858+// Import escPolicy and escUnhandledPolicy from the shared module (pure functions, no Electron deps)
5959// At runtime (from dist/backend/electron/) the path is ../../../app/lib/izui-state.js
6060let escPolicy: (sessionState: string, role: string) => 'close' | 'close-and-restore' | 'nothing';
6161+let escUnhandledPolicy: (sessionState: string, role: string) => 'open-switcher' | 'nothing';
61626263describe('IZUI State Coordinator Tests', () => {
6364 let mockWindows: MockWindow[];
···6970 // @ts-expect-error — shared JS module in app/lib, no .d.ts in tsconfig rootDir
7071 const shared = await import('../../../app/lib/izui-state.js');
7172 escPolicy = shared.escPolicy;
7373+ escUnhandledPolicy = shared.escUnhandledPolicy;
7274 });
73757476 beforeEach(() => {
···795797 assert.strictEqual(escPolicy(coordinator.getState(), 'content'), 'close');
796798 assert.strictEqual(escPolicy(coordinator.getState(), 'workspace'), 'nothing',
797799 'workspace windows must never close on ESC, even in transient sessions');
800800+ });
801801+ });
802802+803803+ describe('escUnhandledPolicy', () => {
804804+ it('should open switcher for workspace in active session', () => {
805805+ assert.strictEqual(escUnhandledPolicy('active', 'workspace'), 'open-switcher');
806806+ });
807807+808808+ it('should open switcher for workspace in idle session', () => {
809809+ assert.strictEqual(escUnhandledPolicy('idle', 'workspace'), 'open-switcher');
810810+ });
811811+812812+ it('should NOT open switcher for workspace in transient session', () => {
813813+ assert.strictEqual(escUnhandledPolicy('transient', 'workspace'), 'nothing',
814814+ 'transient sessions should not trigger switcher — user expects ESC to return to previous app');
815815+ });
816816+817817+ it('should NOT open switcher for workspace in overlay session', () => {
818818+ assert.strictEqual(escUnhandledPolicy('overlay', 'workspace'), 'nothing');
819819+ });
820820+821821+ it('should NOT open switcher for content in active session', () => {
822822+ assert.strictEqual(escUnhandledPolicy('active', 'content'), 'nothing');
823823+ });
824824+825825+ it('should NOT open switcher for child-content in active session', () => {
826826+ assert.strictEqual(escUnhandledPolicy('active', 'child-content'), 'nothing');
827827+ });
828828+829829+ it('should NOT open switcher for overlay role in active session', () => {
830830+ assert.strictEqual(escUnhandledPolicy('active', 'overlay'), 'nothing');
831831+ });
832832+833833+ it('should NOT open switcher for palette in active session', () => {
834834+ assert.strictEqual(escUnhandledPolicy('active', 'palette'), 'nothing');
835835+ });
836836+837837+ it('should NOT open switcher for utility in active session', () => {
838838+ assert.strictEqual(escUnhandledPolicy('active', 'utility'), 'nothing');
839839+ });
840840+841841+ it('should NOT open switcher for quick-view in active session', () => {
842842+ assert.strictEqual(escUnhandledPolicy('active', 'quick-view'), 'nothing');
798843 });
799844 });
800845});
+9-8
backend/electron/session.test.ts
···15381538 */
15391539 function stripRestoreParams(params: Record<string, unknown>): Record<string, unknown> {
15401540 const cleanParams: Record<string, unknown> = {};
15411541- const stripKeys = new Set(['x', 'y', 'width', 'height', 'role', 'escapeMode', 'address', 'transient', 'parentWindowId']);
15411541+ const stripKeys = new Set(['x', 'y', 'width', 'height', 'address', 'transient', 'parentWindowId']);
15421542 for (const [key, value] of Object.entries(params)) {
15431543 if (!stripKeys.has(key)) {
15441544 cleanParams[key] = value;
···15631563 assert.strictEqual(clean.transparent, true, 'transparent should be preserved');
15641564 });
1565156515661566- it('strips role to prevent hasNonContentRole disabling canvas mode', () => {
15671567- // BUG: Web page saved with role:'workspace' gets useCanvas=false on restore
15681568- // because hasNonContentRole = true for 'workspace'.
15661566+ it('preserves role and escapeMode across session restore', () => {
15671567+ // role and escapeMode are semantic properties that affect window behavior
15681568+ // (e.g., ESC handling for extension windows like Groups).
15691569 const params = {
15701570 role: 'workspace',
15711571 escapeMode: 'navigate',
15721572 key: 'https://example.com',
15731573 };
15741574 const clean = stripRestoreParams(params);
15751575- assert.strictEqual(clean.role, undefined, 'role should be stripped');
15761576- assert.strictEqual(clean.escapeMode, undefined, 'escapeMode should be stripped');
15751575+ assert.strictEqual(clean.role, 'workspace', 'role should be preserved');
15761576+ assert.strictEqual(clean.escapeMode, 'navigate', 'escapeMode should be preserved');
15771577 assert.strictEqual(clean.key, 'https://example.com', 'key should be preserved');
15781578 });
15791579···16021602 trackingSourceId: 'open',
16031603 focusable: true,
16041604 debug: false,
16051605+ role: 'content',
16051606 // These should be stripped:
16061606- x: 100, y: 200, role: 'content', address: 'url', transient: false,
16071607+ x: 100, y: 200, address: 'url', transient: false,
16071608 };
16081609 const clean = stripRestoreParams(params);
16091610 assert.strictEqual(clean.key, 'my-window');
···16131614 assert.strictEqual(clean.trackingSourceId, 'open');
16141615 assert.strictEqual(clean.focusable, true);
16151616 assert.strictEqual(clean.debug, false);
16171617+ assert.strictEqual(clean.role, 'content', 'role should be preserved');
16161618 // Verify stripped keys are gone
16171619 assert.strictEqual(clean.x, undefined);
16181620 assert.strictEqual(clean.y, undefined);
16191619- assert.strictEqual(clean.role, undefined);
16201621 assert.strictEqual(clean.address, undefined);
16211622 assert.strictEqual(clean.transient, undefined);
16221623 });
+3-5
backend/electron/session.ts
···588588 // These are internal bookkeeping or context-dependent values that should be
589589 // re-derived from the URL and current environment during restore:
590590 // - x/y/width/height: stale positions from original creation (bounds are authoritative)
591591- // - role: saved role can trigger hasNonContentRole in window-open, preventing
592592- // canvas mode for web pages that had role 'workspace' (Bug: useCanvas=false)
593593- // - escapeMode: affects role detection (escapeMode:'navigate' -> role:'workspace')
594594- // which also disables canvas mode for web pages
595591 // - address: internal bookkeeping param, overwritten by window-open anyway
596592 // - transient/parentWindowId: session state, not applicable to restored windows
593593+ // Note: role and escapeMode are preserved — they're semantic properties that
594594+ // describe how the window should behave (e.g., ESC handling for extension windows).
597595 const cleanParams: Record<string, unknown> = {};
598598- const stripKeys = new Set(['x', 'y', 'width', 'height', 'role', 'escapeMode', 'address', 'transient', 'parentWindowId']);
596596+ const stripKeys = new Set(['x', 'y', 'width', 'height', 'address', 'transient', 'parentWindowId']);
599597 for (const [key, value] of Object.entries(descriptor.params)) {
600598 if (!stripKeys.has(key)) {
601599 cleanParams[key] = value;
+25-1
backend/electron/windows.ts
···2727 getIzuiCoordinator,
2828} from './izui-state.js';
29293030+import { publish, scopes } from './pubsub.js';
3131+3032/**
3133 * Get the appropriate background color based on system theme
3234 * This helps prevent the "white flash" when opening windows in dark mode
···121123}
122124123125/**
126126+ * ESC unhandled policy: second-phase policy consulted ONLY after the renderer
127127+ * returns { handled: false } AND escPolicy() returns 'nothing'.
128128+ *
129129+ * Determines what to do when ESC has nowhere left to navigate.
130130+ * Pure function — no side effects.
131131+ */
132132+export function escUnhandledPolicy(sessionState: string, role: string): 'open-switcher' | 'nothing' {
133133+ // Only workspace windows in active or idle sessions open the switcher.
134134+ // Transient sessions should NOT trigger switcher — user expects ESC to
135135+ // return to previous app, not open more UI.
136136+ if (role === 'workspace' && (sessionState === 'active' || sessionState === 'idle')) return 'open-switcher';
137137+ return 'nothing';
138138+}
139139+140140+/**
124141 * Core ESC handling logic for a BrowserWindow.
125142 * Called when ESC keyDown is detected on the window's own webContents
126143 * or on a webview guest webContents within the window.
···156173157174 if (action === 'close' || action === 'close-and-restore') {
158175 closeOrHideWindow(bw.id);
176176+ } else {
177177+ // escPolicy returned 'nothing' — renderer is at root and window won't close.
178178+ // Consult second-phase policy for unhandled ESC.
179179+ const unhandledAction = escUnhandledPolicy(sessionState, role);
180180+ DEBUG && console.log(`[esc] escUnhandledPolicy(${sessionState}, ${role}) -> ${unhandledAction}`);
181181+ if (unhandledAction === 'open-switcher') {
182182+ publish('peek://system/', scopes.GLOBAL, 'cmd:execute:windows', {});
183183+ }
159184 }
160160- // 'nothing' -> return silently (active workspace/content at root)
161185}
162186163187/**