experiments in a post-browser web
10
fork

Configure Feed

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

docs: IZUI state machine architecture research and role-based redesign plan

Comprehensive analysis of current IZUI system and proposal to replace
ad-hoc escape handling with a role-based state machine. Windows declare
intent (workspace, quick-view, overlay, palette, content, child-content,
utility) and the backend derives all behavior from (sessionState, role).

6-phase migration plan from current patchwork to unified model.
Includes cleanup of app/izui.js frontend layer.

+616
+616
notes/research-izui-state-machine.md
··· 1 + # IZUI State Machine: Architectural Research 2 + 3 + Deep analysis of the IZUI (Invocable Zoom User Interface) system, its current state, and a proposed redesign where window behaviors derive from a unified state machine rather than per-case patches. 4 + 5 + --- 6 + 7 + ## 1. Current State: Complete Map 8 + 9 + ### 1.1 App-Level States (IzuiStateCoordinator) 10 + 11 + The coordinator (`backend/electron/izui-state.ts`) defines four states: 12 + 13 + | State | Meaning | Entry Condition | 14 + |-------|---------|-----------------| 15 + | `idle` | No session, no visible windows | Session ends (last window closes) | 16 + | `transient` | User invoked from background app | `evaluateOnShow()` sees `appFocused === false` | 17 + | `active` | User working within Peek windows | `evaluateOnShow()` sees `appFocused === true` | 18 + | `overlay` | Full-screen overlay active | `enterOverlay()` called | 19 + 20 + **Session model:** A session (`IzuiSession`) wraps all four states and tracks: 21 + - `entryMode`: 'active' or 'transient' -- how the session began 22 + - `windowStack`: array of window IDs in the session 23 + - `overlayWindowId` / `hiddenWindowIds`: overlay state 24 + - `focusedWindowId`: last focused window in session 25 + 26 + **App focus tracking:** `appFocused` is updated by `browser-window-focus` and `browser-window-blur` events in `main.ts`. The blur handler only marks unfocused if `BrowserWindow.getFocusedWindow()` returns null (all Peek windows lost focus, meaning user switched to another app). 27 + 28 + ### 1.2 State Transitions 29 + 30 + ``` 31 + evaluateOnShow() evaluateOnShow() 32 + IDLE -----> TRANSIENT IDLE -----> ACTIVE 33 + | | 34 + | enterOverlay() | enterOverlay() 35 + v v 36 + OVERLAY OVERLAY 37 + | | 38 + | exitOverlay() | exitOverlay() 39 + v v 40 + TRANSIENT ACTIVE 41 + | | 42 + | last window closes | last window closes 43 + v v 44 + IDLE IDLE 45 + ``` 46 + 47 + Critical flaw: **`evaluateOnShow()` both queries AND mutates state.** It checks `appFocused`, then starts/updates the session. This conflates evaluation with transition. When called from the keepLive reuse path, it overwrites the session's `entryMode`, which means a transient session can silently become active (or vice versa) when a second window is shown. 48 + 49 + ### 1.3 Per-Window State (windowRegistry params) 50 + 51 + Each window in `windowRegistry` (Map<number, {source, params}>) carries IZUI-relevant params: 52 + 53 + | Param | Type | Set by | Meaning | 54 + |-------|------|--------|---------| 55 + | `transient` | boolean | window-open handler | Was app unfocused when this window opened? | 56 + | `escapeMode` | string | opener or self-declaration | 'auto', 'close', 'navigate', 'ignore' | 57 + | `modal` | boolean | opener | Close on blur, panel type on macOS | 58 + | `keepLive` | boolean | opener | Hide instead of close | 59 + | `overlay` | boolean | opener | Full-screen overlay behavior | 60 + | `parentWindowId` | number/null | window-open handler | Auto-set from ev.sender (if real parent) | 61 + | `overlayHiddenWindows` | number[] | window-open handler | Windows hidden by overlay | 62 + | `key` | string | opener | Enables window reuse | 63 + 64 + ### 1.4 ESC Handling Chain 65 + 66 + The current escape flow is a five-layer decision tree: 67 + 68 + 1. **`before-input-event` on BrowserWindow webContents** (`windows.ts:addEscHandler`): Catches ESC keyDown, calls `e.preventDefault()`, invokes `handleEscapeForWindow(bw, 'host')` 69 + 2. **`did-attach-webview` listener** (`windows.ts:addEscHandler`): Also attaches ESC interception to webview guest webContents, routing to the same `handleEscapeForWindow(bw, 'webview')` 70 + 3. **`handleEscapeForWindow()`** (`windows.ts`): Reads `escapeMode` from window params, then: 71 + - `'ignore'`: return, do nothing 72 + - `'navigate'`: ask renderer, then apply IZUI close policy (child/transient closes, active root does not) 73 + - `'auto'`: complex branching on isChild, isTransient, isOverlay, isCoordinatorOverlay 74 + - Fallthrough reaches `closeOrHideWindow()` 75 + 4. **`askRendererToHandleEscape()`** (`windows.ts`): Sends `escape-pressed` IPC to renderer, waits 500ms timeout for response 76 + 5. **Preload `escape-pressed` handler** (`preload.js:1637`): Three-priority cascade: 77 + - Open `peek-dialog` or `peek-drawer` -- close topmost one, return `{handled: true}` 78 + - Extension's `_escapeCallback` (registered via `api.escape.onEscape()`) -- call it, return its response 79 + - No handler -- return `{handled: false}` 80 + 81 + **The self-declaration side-channel:** When a renderer calls `api.escape.onEscape()`, the preload also invokes `ipcRenderer.invoke('izui-set-escape-mode', 'navigate')`. This overwrites whatever `escapeMode` the opener set, creating a second path for escape mode to be established. 82 + 83 + ### 1.5 Where State Is Split Across Systems 84 + 85 + State relevant to ESC/lifecycle behavior is scattered across: 86 + 87 + 1. **IzuiStateCoordinator** -- app-level session state (transient/active/overlay) 88 + 2. **windowRegistry params** -- per-window configuration (escapeMode, modal, keepLive, transient, parentWindowId) 89 + 3. **Preload escape callback** -- renderer-side state (`_escapeCallback`) 90 + 4. **closeOrHideWindow()** -- additional behavior switches on `keepLive`, `modal`, `SETTINGS_ADDRESS` 91 + 5. **main.ts closed handler** -- overlay restore logic, parent focus restoration 92 + 6. **modal blur handler** -- registered conditionally in window-open and background window code 93 + 94 + No single place can answer: "What will happen when ESC is pressed in this window?" You must trace through all five layers to determine the outcome. 95 + 96 + --- 97 + 98 + ## 2. Window Taxonomy 99 + 100 + ### 2.1 Complete Window Type Inventory 101 + 102 + | Window Type | Example | escapeMode | modal | keepLive | overlay | transient | parentWindowId | 103 + |-------------|---------|------------|-------|----------|---------|-----------|----------------| 104 + | **Background** | `peek://app/background.html` | N/A (no ESC handler) | false | false | false | N/A | null | 105 + | **Extension Host** | `peek://app/extension-host.html` | N/A (no ESC handler) | false | false | false | N/A | null | 106 + | **Command Palette** | `peek://ext/cmd/panel.html` | auto (default) | true | true | false | varies | null (bg is sender) | 107 + | **Web Page** | via `peek://app/page/index.html` | auto -> navigate (self-declared) | false | false | false | varies | set if real parent | 108 + | **Extension UI (Groups)** | `peek://ext/groups/home.html` | navigate (explicit) | false | false | false | varies | null (bg is sender) | 109 + | **Extension UI (Tags)** | `peek://ext/tags/home.html` | auto -> navigate (self-declared) | false | false | false | varies | null (bg is sender) | 110 + | **Overlay (Windows)** | `peek://windows/windows.html` | auto -> navigate (self-declared) | false | false | true | varies | null (bg is sender) | 111 + | **Peek** | user URL | auto (default) | true | optional | false | varies | null (bg is sender) | 112 + | **Slide** | user URL | auto (default) | true | optional | false | varies | null (bg is sender) | 113 + | **Child Web Page** | opened from page.js `new-window` | auto -> navigate (self-declared) | false | false | false | varies | set to opener | 114 + | **Settings** | `peek://app/settings/settings.html` | auto (default) | varies | false | false | varies | varies | 115 + | **Download** | `peek://ext/cmd/download.html` | auto (default) | false | false | false | varies | null | 116 + | **Diagnostic** | `peek://app/diagnostic.html` | auto (default) | false | false | false | varies | null | 117 + 118 + ### 2.2 Distinguishing Properties 119 + 120 + What makes each window type behave differently: 121 + 122 + - **Who created it:** Background extension windows have `source` = extension URL, but `parentWindowId = null` because background/extension-host are filtered as "not real parents". This means extensions cannot create true child windows -- all their windows look like root windows. 123 + - **How it closes:** `closeOrHideWindow()` branches on `keepLive || modal` (hide instead of close), `SETTINGS_ADDRESS` (always close + maybeHideApp), and default (close + close children + maybeHideApp). 124 + - **How ESC behaves:** Depends on the intersection of `escapeMode`, `isTransient`, `isChild`, `isOverlay`, and whether the renderer has a callback. 125 + - **Self-declaration vs opener declaration:** `escapeMode` can be set by the opener (groups background sets `escapeMode: 'navigate'`) OR by the renderer (preload's `onEscape` auto-sets 'navigate'). The self-declaration always wins because it runs after window creation. 126 + 127 + ### 2.3 The parentWindowId Problem 128 + 129 + The `parentWindowId` mechanism has a fundamental blind spot: **extension background windows are not considered real parents.** The check in `ipc.ts:1770-1772`: 130 + 131 + ```typescript 132 + const INTERNAL_URLS = ['peek://app/background.html', 'peek://app/extension-host.html']; 133 + const isRealParent = openerWindow && !openerWindow.isDestroyed() && !INTERNAL_URLS.some(u => openerUrl === u); 134 + ``` 135 + 136 + This means when the groups extension opens a web page via `api.window.open(url)`, the IPC sender is the extension-host iframe. The extension-host URL matches `INTERNAL_URLS`, so `isRealParent = false`. The web page window gets `parentWindowId = null`. 137 + 138 + However, when the groups *home.html* UI (a visible window) opens a web page, the sender IS a real parent. So the same action (opening a URL from groups) produces different parent relationships depending on whether it's triggered from the background or the UI. 139 + 140 + --- 141 + 142 + ## 3. Every api.window.open() Call 143 + 144 + ### 3.1 Extension Background Openers (from invisible processes) 145 + 146 + | Extension | File | URL Opened | Key Params | Intent | 147 + |-----------|------|------------|------------|--------| 148 + | **peeks** | `extensions/peeks/background.js:83` | `item.address` (user URL) | `modal: true, type: 'panel', key: 'peek:...'` | Transient quick-view panel | 149 + | **slides** | `extensions/slides/background.js:150` | `item.address` (user URL) | `modal: true, type: 'panel', key: '...:edge'` | Edge-anchored transient panel | 150 + | **cmd** | `extensions/cmd/background.js:303` | `peek://ext/cmd/panel.html` | `modal: true, keepLive: true, transparent: true, type: 'panel'` | Persistent command palette | 151 + | **cmd** | `extensions/cmd/background.js:468` | download page URL | `width: 400, height: 200, alwaysOnTop: true` | Ephemeral download window | 152 + | **windows** | `extensions/windows/background.js:56` | `peek://windows/windows.html` | `overlay: true, maximize: true, transparent: true, type: 'panel'` | Full-screen overlay switcher | 153 + | **groups** | `extensions/groups/background.js:70` | `peek://ext/groups/home.html` | `escapeMode: 'navigate', key: address` | Persistent groups browser | 154 + | **groups** | `extensions/groups/background.js:246` | user URL | `groupMode: {...}` | Content page in group context | 155 + | **tags** | `extensions/tags/background.js:21` | `peek://ext/tags/home.html` | `key: 'tags-home'` | Tags browser | 156 + | **editor** | `extensions/editor/background.js:26` | editor URL | `key: ..., escapeMode: 'navigate'` | Note editor | 157 + | **hud** | `extensions/hud/background.js:70` | HUD URL | various | HUD overlay | 158 + | **scripts** | `extensions/scripts/background.js:71` | script URL | various | Script runner | 159 + 160 + ### 3.2 Extension UI Openers (from visible windows) 161 + 162 + | Extension | File | URL Opened | Key Params | Intent | 163 + |-----------|------|------------|------------|--------| 164 + | **groups home** | `extensions/groups/home.js:626` | user URL | `width: 800, height: 600` | Open grouped page (child of groups UI) | 165 + | **tags home** | `extensions/tags/home.js:43` | user URL | `width: 800, height: 600, trackingSource: 'tags'` | Open tagged page | 166 + | **page container** | `app/page/page.js:213` | `e.url` (new-window from webview) | `width: 1024, height: 768` via `izui.openChildWindow()` | Child page from link click | 167 + | **cmd panel** | `extensions/cmd/panel.js:493` | URL | various | Open URL from command result | 168 + 169 + ### 3.3 Core App Openers 170 + 171 + | Module | File | URL Opened | Key Params | Intent | 172 + |--------|------|------------|------------|--------| 173 + | **index.js** | `app/index.js:251` | `peek://app/datastore/viewer.html` | diagnostic | Datastore viewer | 174 + | **index.js** | `app/index.js:263` | `peek://app/diagnostic.html` | diagnostic | App diagnostic | 175 + | **windows.js** | `app/windows.js:46,77` | address param | various | Generic window open helper | 176 + 177 + ### 3.4 IZUI Intent Communication Gaps 178 + 179 + The fundamental problem: **openers declare mechanism, not intent.** `modal: true` means "close on blur" but is used as a proxy for "this is a transient panel." `keepLive: true` means "hide instead of close" but is used as a proxy for "this window persists across invocations." Neither communicates the actual IZUI intent. 180 + 181 + Missing intents that have no first-class representation: 182 + - "This is a quick-view that should close when the user is done glancing" (peeks/slides) 183 + - "This is a workspace browser that should support internal navigation" (groups/tags) 184 + - "This is a command interface that should close after command execution" (cmd) 185 + - "This is an overlay that replaces the current view temporarily" (windows switcher) 186 + - "This is a content window opened in the context of a parent" (child pages) 187 + 188 + --- 189 + 190 + ## 4. ESC Behavior: Expected vs Actual 191 + 192 + ### 4.1 Expected Behavior Matrix 193 + 194 + | Window Type | ESC with internal state | ESC at root (transient) | ESC at root (active) | 195 + |-------------|------------------------|------------------------|---------------------| 196 + | Web page with history | Go back | Close | Do NOT close | 197 + | Web page at root | N/A | Close | Do NOT close | 198 + | Groups (viewing addresses) | Navigate to groups list | Close | Do NOT close | 199 + | Groups (at groups list) | N/A | Close | Do NOT close | 200 + | Tags (with search/filter) | Clear search/filter | Close | Do NOT close | 201 + | Tags (with dialog open) | Close dialog | Close | Do NOT close | 202 + | Tags (at root) | N/A | Close | Do NOT close | 203 + | Peek (modal web page) | Go back (if navigated) | Close | Close (modal always closes) | 204 + | Slide (modal web page) | Go back (if navigated) | Close | Close (modal always closes) | 205 + | Cmd palette | N/A | Close | Close (modal always closes) | 206 + | Windows overlay | Clear search | Close + restore windows | Close + restore windows | 207 + | Child web page | Go back | Close (return to parent) | Close (return to parent) | 208 + 209 + ### 4.2 Current Behavioral Issues 210 + 211 + **Issue 1: Modal windows don't respect internal navigation.** Peeks and slides set `modal: true` but not `escapeMode: 'navigate'`. When a user navigates within a peek (clicks a link), ESC should go back before closing. But with `escapeMode: 'auto'` and `modal: true`, the transient/overlay path fires immediately without asking the renderer. 212 + 213 + **Issue 2: Active root windows with no handler.** If a web page doesn't register an escape handler (the page container's izui.init hasn't run yet, or the handler returns `{handled: false}`), the 'auto' mode falls through to "NOT closing (IZUI policy)". But the only feedback is a console log -- the user sees nothing happen. 214 + 215 + **Issue 3: Overlay restoration in transient mode.** When the windows overlay closes in transient mode, it does NOT restore hidden windows (`main.ts:182-194`). This is intentional (you don't want to show windows when returning to another app), but there's no way for the overlay to know whether the user selected a window (should restore) or just pressed ESC (should not restore). 216 + 217 + **Issue 4: Self-declared escapeMode race.** The preload's `onEscape()` calls `izui-set-escape-mode` to set 'navigate'. But this IPC is asynchronous. If ESC is pressed before the IPC completes (window just opened), the window may still have `escapeMode: 'auto'` and take the wrong path. 218 + 219 + **Issue 5: Dialog closing is preload-global, not window-type-aware.** The preload's topmost-dialog detection (`_findTopmostOpenDialog`) works the same for all window types. But a peek-dialog in a modal window (peek/slide) should probably close the entire window after the dialog closes, while a peek-dialog in an active workspace browser should just close the dialog. 220 + 221 + --- 222 + 223 + ## 5. App Focus States 224 + 225 + ### 5.1 Focus State Tracking 226 + 227 + - **`appFocused`** (IzuiStateCoordinator): Updated by `browser-window-focus`/`browser-window-blur` on the `app` object. True when any Peek window has OS focus. 228 + - **`lastFocusedVisibleWindowId`** (ipc.ts): Updated on window focus, excludes modal windows. Used for command targeting. 229 + - **`lastContentWindowId`** (ipc.ts): Updated when http/https page gains focus. Used for devtools command. 230 + - **`focusedWindowId`** (IzuiSession): Updated via `setFocusedWindow()`, cleared on close/blur. 231 + 232 + ### 5.2 Focus Transitions and IZUI State 233 + 234 + The transient detection is evaluated at window-show time, not continuously. Once a session starts as transient, it stays transient for its duration. This creates a problem: 235 + 236 + 1. User is in another app (appFocused = false) 237 + 2. User presses global hotkey -> window opens, session starts as transient 238 + 3. Peek window gains focus -> `browser-window-focus` fires -> `appFocused = true` 239 + 4. User clicks on another Peek window -> still transient because session.entryMode wasn't updated 240 + 5. User opens a new window from within Peek -> `evaluateOnShow()` sees `appFocused = true` but session already exists, so it UPDATES entryMode to 'active' 241 + 242 + Step 5 is the key flaw: **opening a second window from within a transient session silently converts it to active.** The `evaluateOnShow()` function in `izui-state.ts:186-197` always updates `session.entryMode` to the current evaluation result. 243 + 244 + ### 5.3 Focus and Modal Windows 245 + 246 + Modal windows (`type: 'panel'`, close-on-blur) interact poorly with IZUI focus tracking. When a cmd palette opens: 247 + 1. It gets focus, fires `browser-window-focus` -> `appFocused = true` 248 + 2. It's excluded from `lastFocusedVisibleWindowId` tracking (correct) 249 + 3. But it's NOT excluded from IZUI focus -- opening a window from cmd would evaluate as 'active' 250 + 251 + This is actually the correct behavior most of the time (user is actively working in Peek), but it means **the distinction between "user invoked from another app" and "user is working in Peek" is only reliable for the very first window of a session.** 252 + 253 + --- 254 + 255 + ## 6. Proposed State Machine Design 256 + 257 + ### 6.1 Core Principle: Intent Declaration, Not Mechanism Configuration 258 + 259 + Instead of openers specifying `modal: true, keepLive: true, escapeMode: 'navigate'` and having the backend infer behavior, openers should declare **what kind of window this is** and **what IZUI role it plays**. The backend derives all mechanism from intent. 260 + 261 + ### 6.2 Window Roles (replacing ad-hoc params) 262 + 263 + ``` 264 + windowRole: 'workspace' | 'quick-view' | 'overlay' | 'palette' | 'content' | 'child-content' | 'utility' 265 + ``` 266 + 267 + | Role | Description | Current Equivalent | Derived Behavior | 268 + |------|-------------|-------------------|------------------| 269 + | `workspace` | Long-lived browser/manager UI | groups, tags with `escapeMode: 'navigate'` | ESC navigates internally, never closes in active mode. Close only in transient mode at root. | 270 + | `quick-view` | Ephemeral view triggered by hotkey | peeks, slides with `modal: true` | ESC always closes (after internal nav). Close on blur. May be keepLive. | 271 + | `overlay` | Full-screen takeover | windows switcher with `overlay: true` | ESC closes and restores hidden windows. Hides all other windows on open. | 272 + | `palette` | Command/search input | cmd with `modal: true, keepLive: true` | ESC closes. Close on blur. Always keepLive. | 273 + | `content` | Web page opened by user action | web pages from groups, tags, cmd | ESC navigates back. Close in transient at root. Parent-aware. | 274 + | `child-content` | Content opened from another content window | links from page.js | Same as content but always closes on ESC at root (to return to parent). | 275 + | `utility` | Small helper window | download, diagnostic | ESC closes. No special behavior. | 276 + 277 + ### 6.3 App-Level State Machine (Refined) 278 + 279 + ``` 280 + States: IDLE, TRANSIENT, ACTIVE, OVERLAY 281 + 282 + Transitions: 283 + IDLE -> TRANSIENT: window opens while app unfocused 284 + IDLE -> ACTIVE: window opens while app focused 285 + TRANSIENT -> ACTIVE: user explicitly focuses Peek (not just opening a window) 286 + ACTIVE -> TRANSIENT: [should not happen - active sessions stay active] 287 + ANY -> OVERLAY: overlay window opens 288 + OVERLAY -> previous: overlay window closes 289 + ANY -> IDLE: last window closes 290 + ``` 291 + 292 + Key change: **Once a session becomes ACTIVE, it stays ACTIVE.** The current bug where `evaluateOnShow()` can flip a transient session to active should be the one-way ratchet: transient-to-active is allowed, active-to-transient is not. This matches user intent -- if you were in another app and then started interacting with Peek, you've committed to an active session. 293 + 294 + ### 6.4 ESC as Derived Behavior 295 + 296 + Given `(appState, windowRole, rendererState)`, ESC behavior is a pure function: 297 + 298 + ``` 299 + escAction(appState, windowRole, rendererState) = 300 + -- Step 1: Renderer-internal actions always take priority 301 + if rendererState.hasOpenDialog: 302 + return CLOSE_DIALOG 303 + 304 + if rendererState.hasInternalNavigation: 305 + return NAVIGATE_INTERNAL -- ask renderer to handle 306 + 307 + -- Step 2: Window role determines root behavior 308 + match windowRole: 309 + 'quick-view', 'palette', 'utility': 310 + return CLOSE -- these always close at root regardless of app state 311 + 312 + 'overlay': 313 + return CLOSE_AND_RESTORE 314 + 315 + 'child-content': 316 + return CLOSE -- always close to return to parent 317 + 318 + 'workspace': 319 + if appState == TRANSIENT: 320 + return CLOSE 321 + else: 322 + return NOTHING -- active workspace at root: ESC does nothing 323 + 324 + 'content': 325 + if appState == TRANSIENT: 326 + return CLOSE 327 + else: 328 + return NOTHING -- active content at root: ESC does nothing 329 + ``` 330 + 331 + This eliminates all the special-casing in `handleEscapeForWindow()`. The function becomes: 332 + 333 + 1. Check renderer for internal actions (dialog, navigation) 334 + 2. Look up `(appState, windowRole)` in the policy table 335 + 3. Execute the action 336 + 337 + ### 6.5 How Openers Declare Intent 338 + 339 + ```javascript 340 + // Peek (quick-view) 341 + api.window.open(url, { 342 + role: 'quick-view', 343 + keepLive: true, // mechanism, not intent -- still useful 344 + width: 800, height: 600 345 + }); 346 + 347 + // Groups UI (workspace) 348 + api.window.open('peek://ext/groups/home.html', { 349 + role: 'workspace', 350 + key: 'groups-home' 351 + }); 352 + 353 + // Web page from groups (content) 354 + api.window.open(url, { 355 + role: 'content' 356 + // parentWindowId auto-set by backend 357 + }); 358 + 359 + // Windows switcher (overlay) 360 + api.window.open(url, { 361 + role: 'overlay' 362 + }); 363 + 364 + // Cmd palette 365 + api.window.open(url, { 366 + role: 'palette', 367 + keepLive: true 368 + }); 369 + ``` 370 + 371 + Defaults based on context: 372 + - If URL is `http://` or `https://` and no role specified: `'content'` 373 + - If URL is `peek://` and no role specified: `'utility'` 374 + - If `modal: true` and no role specified: `'quick-view'` 375 + - If `overlay: true` and no role specified: `'overlay'` 376 + 377 + ### 6.6 Renderer Contract 378 + 379 + The renderer's escape handler contract becomes simpler: 380 + 381 + ```javascript 382 + api.escape.onEscape(() => { 383 + // Return what YOU handled internally: 384 + // { handled: true } -- I navigated/closed something, no further action 385 + // { handled: false } -- I have nothing to do, let the state machine decide 386 + // 387 + // You NEVER need to know about transient/active/window role. 388 + // The backend will decide whether to close based on the state machine. 389 + }); 390 + ``` 391 + 392 + The preload's dialog auto-close remains as-is (it's a renderer-internal concern). The renderer never needs to call `izui-close-self` or check `isTransient` -- those decisions belong to the backend. 393 + 394 + --- 395 + 396 + ## 7. Window Lifecycle Within the State Machine 397 + 398 + ### 7.1 Creation 399 + 400 + ``` 401 + opener calls api.window.open(url, { role, ...mechanism }) 402 + | 403 + v 404 + backend evaluates: appFocused? -> determine session state 405 + | 406 + v 407 + backend resolves role (explicit > inferred from params > inferred from URL) 408 + | 409 + v 410 + backend creates BrowserWindow with mechanism params (size, position, transparency, etc.) 411 + | 412 + v 413 + backend registers in windowRegistry with { role, sessionState } 414 + | 415 + v 416 + backend derives mechanism from role: 417 + role == 'quick-view' -> close-on-blur handler, panel type 418 + role == 'overlay' -> hide other windows, maximize 419 + role == 'palette' -> panel type, alwaysOnTop, close-on-blur 420 + role == 'workspace' -> standard window 421 + role == 'content' -> standard window, parent tracking 422 + role == 'child-content' -> standard window, parent tracking 423 + role == 'utility' -> standard window 424 + | 425 + v 426 + backend pushes window to session stack 427 + ``` 428 + 429 + ### 7.2 ESC Processing 430 + 431 + ``` 432 + ESC keyDown intercepted (before-input-event or webview guest) 433 + | 434 + v 435 + handleEscapeForWindow(bw) 436 + | 437 + v 438 + look up window role from registry 439 + | 440 + v 441 + askRendererToHandleEscape(bw) 442 + | 443 + v 444 + preload checks: 445 + 1. open dialog/drawer -> close it, return { handled: true } 446 + 2. _escapeCallback -> call it, return result 447 + 3. no handler -> return { handled: false } 448 + | 449 + v 450 + if renderer handled: done 451 + | 452 + v 453 + escAction(coordinator.getState(), window.role, rendererResponse) 454 + -> CLOSE: closeOrHideWindow(bw.id) 455 + -> CLOSE_AND_RESTORE: closeOrHideWindow(bw.id) [overlay restore in close handler] 456 + -> NOTHING: do nothing 457 + ``` 458 + 459 + ### 7.3 Close/Hide 460 + 461 + ``` 462 + closeOrHideWindow(id) 463 + | 464 + v 465 + look up window role and params from registry 466 + | 467 + v 468 + if keepLive: hide (same as current) 469 + if not keepLive: close (same as current) 470 + | 471 + v 472 + close handler in main.ts: 473 + - clear IZUI coordinator state 474 + - if child: focus parent 475 + - if overlay: restore hidden windows (unless transient session AND user didn't select a window) 476 + - publish window:closed 477 + - if last window: end session -> IDLE 478 + ``` 479 + 480 + ### 7.4 Destruction 481 + 482 + Same as current: `window.close()` triggers the 'closed' handler which cleans up the registry and coordinator. 483 + 484 + --- 485 + 486 + ## 8. Cross-Window Effects 487 + 488 + ### 8.1 Current Cross-Window Behaviors 489 + 490 + | Action | Effect | Driven by | 491 + |--------|--------|-----------| 492 + | Overlay opens | All visible windows hidden | `window-open` handler checks `overlay: true` | 493 + | Overlay closes (active) | Hidden windows restored | `main.ts` closed handler | 494 + | Overlay closes (transient) | Hidden windows NOT restored | `main.ts` closed handler checks `transient` | 495 + | Child closes | Parent gets focus | `main.ts` closed handler checks `parentWindowId` | 496 + | Last window closes | Session ends, app hides | `popWindow()` -> `endSession()`, `maybeHideApp()` | 497 + | Modal loses focus | Modal closes/hides | blur handler registered in `window-open` | 498 + 499 + ### 8.2 State-Machine-Driven Cross-Window Rules 500 + 501 + In the proposed model, cross-window effects derive from the combination of window role and session state: 502 + 503 + **Rule 1: Overlay lifecycle** 504 + - On open: hide all non-background windows (same as current) 505 + - On close in ACTIVE session: restore all hidden windows 506 + - On close in TRANSIENT session with selection: restore selected window, close others 507 + - On close in TRANSIENT session without selection: restore nothing, app hides 508 + 509 + **Rule 2: Parent-child lifecycle** 510 + - `child-content` windows always have a `parentWindowId` 511 + - On child close: focus parent (same as current) 512 + - On parent close: close all children (same as current via `closeChildWindows`) 513 + 514 + **Rule 3: Session lifecycle** 515 + - Transient session: when last visible window closes, hide app 516 + - Active session: when last visible window closes, session ends, may hide app based on dock pref 517 + - Overlay doesn't count as "last window" for session ending 518 + 519 + **Rule 4: Focus effects** 520 + - Modal roles (`quick-view`, `palette`) don't update `lastFocusedVisibleWindowId` 521 + - All focus changes update `appFocused` through existing browser-window-focus/blur 522 + - First window gaining focus in a transient session can ratchet session to active 523 + 524 + --- 525 + 526 + ## 9. Migration Path 527 + 528 + ### Phase 1: Add `role` param without changing behavior 529 + 530 + 1. Add `role` field to `windowRegistry` params 531 + 2. In `window-open` handler, compute role from existing params if not provided: 532 + - `overlay: true` -> `'overlay'` 533 + - `modal: true && keepLive: true` -> `'palette'` 534 + - `modal: true` -> `'quick-view'` 535 + - `escapeMode: 'navigate'` -> `'workspace'` 536 + - `parentWindowId != null && url is http/https` -> `'child-content'` 537 + - `url is http/https` -> `'content'` 538 + - else -> `'utility'` 539 + 3. Log computed role alongside existing params for comparison 540 + 4. No behavior change -- existing code paths still execute 541 + 542 + ### Phase 2: Rewrite `handleEscapeForWindow()` using role 543 + 544 + 1. Replace the nested if/else tree with the policy table lookup 545 + 2. Keep `askRendererToHandleEscape()` as-is (renderer contract unchanged) 546 + 3. Remove `escapeMode` from the decision path -- role determines everything 547 + 4. Keep `escapeMode: 'ignore'` as an override (some windows truly need ESC passthrough) 548 + 5. Unit test each `(sessionState, role)` combination 549 + 550 + ### Phase 3: Update openers to declare role 551 + 552 + 1. Update each extension's `api.window.open()` calls to pass `role` explicitly 553 + 2. Remove `escapeMode: 'navigate'` from openers (derived from role now) 554 + 3. Remove `modal: true` as an IZUI signal (keep it only for actual BrowserWindow behavior) 555 + 4. Remove `self-declaration` in preload's `onEscape()` -- the role already determines behavior 556 + 557 + ### Phase 4: Fix session state management 558 + 559 + 1. Make transient-to-active a one-way ratchet in `evaluateOnShow()` 560 + 2. Remove the `evaluateOnShow()` call from the keepLive reuse path -- session state shouldn't change just because a hidden window was re-shown 561 + 3. Add a dedicated `promoteToActive()` method that only fires when the user deliberately focuses Peek (not just because a new window opened) 562 + 563 + ### Phase 5: Remove self-declaration mechanism 564 + 565 + 1. Remove `izui-set-escape-mode` IPC handler 566 + 2. Remove the auto-set in preload's `onEscape()` registration 567 + 3. The renderer's `onEscape` callback becomes purely informational: "I have internal navigation to handle." The backend no longer needs to know that the renderer registered a handler -- it just asks, and handles the response according to role policy. 568 + 569 + ### Phase 6: Clean up `app/` frontend IZUI layer 570 + 571 + 1. **Delete `app/izui.js`** entirely -- it's already a deprecated shim where every method delegates to the backend. No frontend IZUI module needed. 572 + 2. **Update `app/page/page.js`** -- replace `izui.init({ onEscape: ... })` with direct `api.escape.onEscape()` call. Remove `import izui from '../izui.js'`. Replace `izui.openChildWindow()` with `api.window.open()`. 573 + 3. **Remove any other `izui.js` imports** across `app/` -- search for all consumers and replace with direct `api` calls. 574 + 4. The frontend API surface for IZUI becomes just `api.escape.onEscape(callback)` -- one function. Everything else is backend policy. 575 + 5. This simplifies the contract: **frontend reports state** ("I have a dialog open", "I navigated back", "nothing to do"), **backend makes decisions** (close, hide, nothing). 576 + 577 + Note: This clean split will make the Tauri desktop implementation straightforward -- the backend role/policy system ports directly, and the frontend contract (`api.escape.onEscape`) is platform-agnostic. 578 + 579 + --- 580 + 581 + ## 10. Key Insights 582 + 583 + ### 10.1 The Fundamental Confusion 584 + 585 + The current system conflates three orthogonal concerns: 586 + 587 + 1. **Window mechanism** (how the window behaves at the OS level): frame, transparency, position, alwaysOnTop, panel type 588 + 2. **Window role** (what the window IS in the IZUI model): workspace, quick-view, overlay, content 589 + 3. **Session context** (the circumstances of invocation): transient vs active 590 + 591 + These three concerns should be independent. Currently, `modal: true` is used to mean both "close on blur" (mechanism) and "this is a transient quick-view" (role). `escapeMode: 'navigate'` is used to mean both "ask renderer before closing" (mechanism) and "this is a workspace with internal navigation" (role). 592 + 593 + ### 10.2 Why Per-Extension Hacks Keep Appearing 594 + 595 + Each new extension encounters the same problem: the default ESC behavior doesn't match their intent, so they add workarounds: 596 + - Groups sets `escapeMode: 'navigate'` to prevent closing at root 597 + - Tags registers `onEscape` with `{handled: true}` for search/filter clearing 598 + - The preload detects `peek-dialog` elements to auto-close them 599 + - Page.js uses `izui.init()` to register a webview back-navigation handler 600 + 601 + Each is correct for its window, but each is a local fix to a systemic problem. With roles, each extension just declares what it is, and correct behavior falls out of the state machine. 602 + 603 + ### 10.3 The Self-Declaration Anti-Pattern 604 + 605 + The `izui-set-escape-mode` mechanism (renderer self-declares its escape mode) is backwards. It says: "I'm telling you what I am after you've already created me." This creates race conditions and makes it impossible to know a window's behavior at creation time. In the role-based model, the opener declares intent at creation time, and the renderer's escape handler is just a callback that the state machine consults -- it doesn't change the state machine's policy. 606 + 607 + ### 10.4 Modal As Role vs Modal As Mechanism 608 + 609 + `modal: true` currently does two things: sets BrowserWindow type to 'panel' on macOS, and registers a blur handler that closes the window. The first is mechanism (useful for OS window management). The second is a role behavior (quick-views should close on blur, workspaces should not). These need to be separated: 610 + 611 + - `type: 'panel'` -- mechanism, keep as BrowserWindow option 612 + - Close-on-blur -- derived from role: `quick-view` and `palette` close on blur; others don't 613 + 614 + ### 10.5 The keepLive Orthogonality 615 + 616 + `keepLive` is genuinely orthogonal to role -- any role can be keepLive. A `quick-view` peek may be keepLive (hide on ESC, re-show on hotkey) or not (destroy on ESC). A `palette` is always keepLive (cmd palette persists). This should remain as a separate mechanism param, not folded into role.