experiments in a post-browser web
10
fork

Configure Feed

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

feat(page): page-host FSM Phase 3 — fsmState is the source of truth

Phases 1 + 2 introduced the FSM and dispatched transitions at every
entry/exit, but `isDragging` / `isResizing` / `isMaximized` were still
the actual source of truth — the FSM was shadow state kept in sync via
idempotent applyFsmEffect calls. Phase 3 flips that: fsmState is now the
only place lifecycle state lives.

Reads → predicates:
* inMaximized() ↔ fsmState === MAXIMIZED
* inDragging() ↔ fsmState === DRAGGING || DRAGGING_OUT_OF_MAXIMIZED
* inResizing() ↔ fsmState === RESIZING

Every read of the legacy flags (16 isDragging, 5 isResizing, 17
isMaximized sites) now goes through these helpers. The flag declarations
and every inline `flag = true/false` write are deleted.

applyFsmEffect cleanup:
* SET_BODY_MAXIMIZED no longer writes `isMaximized = effect.value` —
the variable doesn't exist anymore. The effect just toggles the
body class; fsmState (already assigned by dispatchFsm before
effects run) is authoritative.
* Comments updated to reflect that effects translate state changes
into idempotent DOM/URL updates, not the other way around.

toggleMaximize restructure:
* dispatchFsm(TOGGLE_MAXIMIZE) now runs BEFORE updatePositions /
updateUrlParams / setBounds, so those see the new fsmState +
updated screenBounds. Previously dispatch was at the end of each
branch and inline `isMaximized = …` writes kept the shadow flag
in sync; without those writes the dispatch must move forward so
that `inMaximized()` reads inside updatePositions / updateUrlParams
return the post-toggle value.
* Both branches first mutate screenBounds + clear/capture pre-max
bounds, then dispatch, then setBounds. The dispatch's effects
(SET_BODY_MAXIMIZED, SET_HANDLES_VISIBLE→updatePositions,
SET_URL_MAXIMIZED→updateUrlParams) now do the work the old inline
code used to do.

startDrag restructure:
* Captures `wasMaximized = inMaximized()` BEFORE dispatchFsm
(DRAG_START flips MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED, so a
post-dispatch `inMaximized()` returns false).
* Inline `document.body.classList.remove('maximized')` removed —
the FSM's SET_BODY_MAXIMIZED(false) effect handles it.
* Inline `isDragging = true` removed — DRAG_START transitions the
FSM into DRAGGING.

Drag-end paths (toggleMaximize cancel, webview mouseup, document
mousemove orphan-cleanup, document mouseup) all now read inDragging()
and rely on dispatchFsm(DRAG_END) for the state transition; inline
`isDragging = false` writes deleted.

Pre-maximize bounds (preMaximizeBounds, preMaximizeWindowBounds,
dragOutOfMaximizeWindowSize) remain module-scoped — moving them into
FSM context (the design doc's "small attribute bag") is deferred. They
behave correctly today, and the migration would expand the FSM module
shape (state → {state, context}) and unit-test surface beyond the scope
of "delete the flags."

Test results:
* unit: 628/628
* page-host-fsm: 5/5
* session-restore-page-host: 2/2
* reopen-closed-window: 5/5
* page-load-failure: 5/5

Tasks doc updated; Phase 3 entry marked done with the regression-suite
roll call.

+128 -58
+69
--
··· 1 + feat(page): page-host FSM Phase 3 — fsmState is the source of truth 2 + 3 + Phases 1 + 2 introduced the FSM and dispatched transitions at every 4 + entry/exit, but `isDragging` / `isResizing` / `isMaximized` were still 5 + the actual source of truth — the FSM was shadow state kept in sync via 6 + idempotent applyFsmEffect calls. Phase 3 flips that: fsmState is now the 7 + only place lifecycle state lives. 8 + 9 + Reads → predicates: 10 + * inMaximized() ↔ fsmState === MAXIMIZED 11 + * inDragging() ↔ fsmState === DRAGGING || DRAGGING_OUT_OF_MAXIMIZED 12 + * inResizing() ↔ fsmState === RESIZING 13 + 14 + Every read of the legacy flags (16 isDragging, 5 isResizing, 17 15 + isMaximized sites) now goes through these helpers. The flag declarations 16 + and every inline `flag = true/false` write are deleted. 17 + 18 + applyFsmEffect cleanup: 19 + * SET_BODY_MAXIMIZED no longer writes `isMaximized = effect.value` — 20 + the variable doesn't exist anymore. The effect just toggles the 21 + body class; fsmState (already assigned by dispatchFsm before 22 + effects run) is authoritative. 23 + * Comments updated to reflect that effects translate state changes 24 + into idempotent DOM/URL updates, not the other way around. 25 + 26 + toggleMaximize restructure: 27 + * dispatchFsm(TOGGLE_MAXIMIZE) now runs BEFORE updatePositions / 28 + updateUrlParams / setBounds, so those see the new fsmState + 29 + updated screenBounds. Previously dispatch was at the end of each 30 + branch and inline `isMaximized = …` writes kept the shadow flag 31 + in sync; without those writes the dispatch must move forward so 32 + that `inMaximized()` reads inside updatePositions / updateUrlParams 33 + return the post-toggle value. 34 + * Both branches first mutate screenBounds + clear/capture pre-max 35 + bounds, then dispatch, then setBounds. The dispatch's effects 36 + (SET_BODY_MAXIMIZED, SET_HANDLES_VISIBLE→updatePositions, 37 + SET_URL_MAXIMIZED→updateUrlParams) now do the work the old inline 38 + code used to do. 39 + 40 + startDrag restructure: 41 + * Captures `wasMaximized = inMaximized()` BEFORE dispatchFsm 42 + (DRAG_START flips MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED, so a 43 + post-dispatch `inMaximized()` returns false). 44 + * Inline `document.body.classList.remove('maximized')` removed — 45 + the FSM's SET_BODY_MAXIMIZED(false) effect handles it. 46 + * Inline `isDragging = true` removed — DRAG_START transitions the 47 + FSM into DRAGGING. 48 + 49 + Drag-end paths (toggleMaximize cancel, webview mouseup, document 50 + mousemove orphan-cleanup, document mouseup) all now read inDragging() 51 + and rely on dispatchFsm(DRAG_END) for the state transition; inline 52 + `isDragging = false` writes deleted. 53 + 54 + Pre-maximize bounds (preMaximizeBounds, preMaximizeWindowBounds, 55 + dragOutOfMaximizeWindowSize) remain module-scoped — moving them into 56 + FSM context (the design doc's "small attribute bag") is deferred. They 57 + behave correctly today, and the migration would expand the FSM module 58 + shape (state → {state, context}) and unit-test surface beyond the scope 59 + of "delete the flags." 60 + 61 + Test results: 62 + * unit: 628/628 63 + * page-host-fsm: 5/5 64 + * session-restore-page-host: 2/2 65 + * reopen-closed-window: 5/5 66 + * page-load-failure: 5/5 67 + 68 + Tasks doc updated; Phase 3 entry marked done with the regression-suite 69 + roll call.
+55 -57
app/page/page.js
··· 149 149 }; 150 150 151 151 // --- Maximize state --- 152 - // `isMaximized` is the legacy boolean read by drag/resize/etc. It mirrors 153 - // the FSM's MAXIMIZED state. New code should query `fsmState` directly. 154 - // See docs/page-host-state-machine.md for the rollout plan. 152 + // fsmState is the source of truth — read via the inMaximized()/inDragging()/ 153 + // inResizing() predicates below. See docs/page-host-state-machine.md. 155 154 let fsmState = FSM_INITIAL_STATE; 156 - let isMaximized = false; 157 155 let preMaximizeBounds = null; // { x, y, width, height } screen bounds before maximize 158 156 // Raw WINDOW bounds captured before maximize (used to restore to the exact same 159 157 // window rect, bypassing computeWindowBounds margin math which may differ from ··· 161 159 // canvas margin adjustment in ipc.ts window-open is skipped). 162 160 let preMaximizeWindowBounds = null; 163 161 164 - // Apply FSM effects to the DOM. Today the FSM owns init transitions only; 165 - // toggleMaximize still mutates state directly (and dispatches to the FSM 166 - // to keep `fsmState` in sync). Future PRs will route every transition 167 - // through here. 162 + // Apply FSM effects to the DOM. fsmState is already the source of truth by 163 + // the time effects run (dispatchFsm assigns next.state before iterating); 164 + // effects translate state changes into idempotent DOM/URL updates. 168 165 function applyFsmEffect(effect) { 169 166 switch (effect.type) { 170 167 case FSM_EFFECTS.SET_BODY_MAXIMIZED: 171 168 if (effect.value) document.body.classList.add('maximized'); 172 169 else document.body.classList.remove('maximized'); 173 - isMaximized = effect.value; 174 170 return; 175 171 case FSM_EFFECTS.SET_HANDLES_VISIBLE: 176 172 // No DOM write — handle visibility is set by updatePositions() based 177 - // on isMaximized. Run updatePositions to apply. 173 + // on fsmState. Run updatePositions to apply. 178 174 updatePositions(); 179 175 return; 180 176 case FSM_EFFECTS.SET_URL_MAXIMIZED: 181 - // updateUrlParams() already reflects isMaximized, so just call it. 177 + // updateUrlParams() already reflects fsmState, so just call it. 182 178 updateUrlParams(); 183 179 return; 184 180 case FSM_EFFECTS.CAPTURE_PRE_MAXIMIZE: ··· 216 212 // code should never read this; use the FSM's transition() API. 217 213 window.__pageFsmState = fsmState; 218 214 } 215 + 216 + // FSM state predicates — single source of truth for "what state am I in?". 217 + // Phase 3 deleted the standalone `isMaximized` / `isDragging` / `isResizing` 218 + // flags; fsmState is now the only place lifecycle state lives. 219 + const inMaximized = () => fsmState === FSM_STATES.MAXIMIZED; 220 + const inDragging = () => 221 + fsmState === FSM_STATES.DRAGGING || 222 + fsmState === FSM_STATES.DRAGGING_OUT_OF_MAXIMIZED; 223 + const inResizing = () => fsmState === FSM_STATES.RESIZING; 219 224 220 225 // NOTE: The window always includes navbar space (NAVBAR_HEIGHT) to avoid 221 226 // visual jumps on show/hide. Navbar visibility is CSS-only — no window resize. ··· 303 308 let extraWidth = 0; 304 309 305 310 function computeWindowBounds(sb) { 306 - if (isMaximized && preMaximizeBounds) { 311 + if (inMaximized() && preMaximizeBounds) { 307 312 // Maximized: window fills the display work area 308 313 return { 309 314 x: Math.round(sb.x), ··· 335 340 // raw OS window bounds — when maximized, screenBounds equals the work 336 341 // area (window == webview) and re-adding canvas margins on reopen would 337 342 // overshoot the display. 338 - if (isMaximized) { 343 + if (inMaximized()) { 339 344 url.searchParams.set('maximized', '1'); 340 345 } else { 341 346 url.searchParams.delete('maximized'); ··· 347 352 } 348 353 349 354 function updatePositions() { 350 - if (isMaximized) { 355 + if (inMaximized()) { 351 356 updatePositionsMaximized(); 352 357 return; 353 358 } ··· 582 587 try { 583 588 // Cancel any active drag — drag handlers running during the await would 584 589 // overwrite screenBounds and call setBounds with stale values. 585 - if (isDragging) { 586 - isDragging = false; 590 + if (inDragging()) { 587 591 dragOverlay.classList.remove('active'); 588 592 document.body.style.cursor = ''; 589 593 navbar.style.cursor = 'grab'; ··· 597 601 pendingBoundsUpdate = null; 598 602 } 599 603 600 - if (isMaximized) { 604 + if (inMaximized()) { 601 605 // Restore from maximize. Use the saved WINDOW bounds directly — they 602 606 // were captured from the live window before maximize, so restoring with 603 607 // them guarantees byte-for-byte bounds equivalence regardless of any 604 608 // canvas margin adjustments (which may or may not be applied in 605 609 // headless/test environments). 606 - isMaximized = false; 607 - document.body.classList.remove('maximized'); 608 - 609 610 const restoreWindowBounds = preMaximizeWindowBounds; 610 611 if (preMaximizeBounds) { 611 612 screenBounds.x = preMaximizeBounds.x; ··· 616 617 } 617 618 preMaximizeWindowBounds = null; 618 619 619 - updatePositions(); 620 + // Dispatch flips fsmState MAXIMIZED→NORMAL and fires effects: 621 + // SET_BODY_MAXIMIZED(false), SET_HANDLES_VISIBLE(true) (→updatePositions), 622 + // SET_URL_MAXIMIZED(false) (→updateUrlParams). updatePositions and 623 + // updateUrlParams now see the restored screenBounds + NORMAL state. 624 + dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 625 + 620 626 centerColumn.style.opacity = '0'; 621 627 if (restoreWindowBounds) { 622 628 await api.window.setBounds(restoreWindowBounds); ··· 624 630 await api.window.setBounds(computeWindowBounds(screenBounds)); 625 631 } 626 632 centerColumn.style.opacity = '1'; 627 - updateUrlParams(); 628 - // Sync FSM (effects are idempotent against state we already mutated). 629 - dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 630 633 return; 631 634 } 632 635 ··· 663 666 preMaximizeWindowBounds = null; 664 667 } 665 668 666 - isMaximized = true; 667 - document.body.classList.add('maximized'); 668 - 669 669 // screenBounds now represents the full work area (window = work area in maximized mode) 670 670 screenBounds.x = workArea.x; 671 671 screenBounds.y = workArea.y; 672 672 screenBounds.width = workArea.width; 673 673 screenBounds.height = workArea.height; 674 674 675 - updatePositions(); 675 + // Dispatch flips fsmState NORMAL→MAXIMIZED and fires effects: 676 + // SET_BODY_MAXIMIZED(true), SET_HANDLES_VISIBLE(false) (→updatePositions 677 + // uses maximized layout), SET_URL_MAXIMIZED(true) (→updateUrlParams). 678 + dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 679 + 676 680 centerColumn.style.opacity = '0'; 677 681 await api.window.setBounds(computeWindowBounds(screenBounds)); 678 682 centerColumn.style.opacity = '1'; 679 - updateUrlParams(); 680 - // Sync FSM (effects are idempotent against state we already mutated). 681 - dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 682 683 } catch (err) { 683 684 console.error('[page] toggleMaximize failed:', err); 684 685 } ··· 702 703 // Expands the window symmetrically. Flexbox keeps the center column in place. 703 704 // Returns a promise that resolves when the window bounds have been applied. 704 705 function setWindowPadding(width) { 705 - if (isMaximized) return Promise.resolve(); 706 + if (inMaximized()) return Promise.resolve(); 706 707 width = Math.max(0, width); 707 708 if (width === extraWidth) return Promise.resolve(); 708 709 extraWidth = width; ··· 950 951 // 1. Navbar background: instant drag (no hold delay) 951 952 // 2. Anywhere else: hold for dragHoldDelay (default 1s) then drag 952 953 // Drag now moves the BrowserWindow itself via setBounds() IPC. 954 + // Lifecycle (DRAGGING / DRAGGING_OUT_OF_MAXIMIZED) lives in the FSM — 955 + // see inDragging() above. Variables below are per-drag attribute state only. 953 956 954 - let isDragging = false; 955 957 let dragStartScreenX = 0; 956 958 let dragStartScreenY = 0; 957 959 let dragStartBoundsX = 0; ··· 985 987 })(); 986 988 987 989 function startDrag(screenX, screenY) { 990 + // Capture pre-dispatch state: DRAG_START flips MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED. 991 + const wasMaximized = inMaximized(); 992 + 988 993 // FSM: NORMAL → DRAGGING or MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED. 989 - // Effects (overlay, body class, URL param) are idempotent against 990 - // the inline mutations below. 994 + // Effects (overlay, body class, URL param) clean up the maximized DOM state. 991 995 dispatchFsm({ type: FSM_EVENTS.DRAG_START }); 992 996 993 997 // Drag-out-of-maximize: restore original size, center on cursor 994 - if (isMaximized && preMaximizeBounds) { 998 + if (wasMaximized && preMaximizeBounds) { 995 999 const restoreW = preMaximizeBounds.width; 996 1000 const restoreH = preMaximizeBounds.height; 997 1001 const savedWindowBounds = preMaximizeWindowBounds; 998 - isMaximized = false; 999 - document.body.classList.remove('maximized'); 1000 1002 1001 1003 // Center the restored window on the cursor 1002 1004 screenBounds.x = screenX - restoreW / 2; ··· 1029 1031 dragOutOfMaximizeWindowSize = null; 1030 1032 } 1031 1033 } 1032 - isDragging = true; 1033 1034 dragStartScreenX = screenX; 1034 1035 dragStartScreenY = screenY; 1035 1036 dragStartBoundsX = screenBounds.x; ··· 1064 1065 // Any movement during hold period cancels the timer, allowing text selection. 1065 1066 document.addEventListener('mousedown', (e) => { 1066 1067 if (e.target.closest('.resize-handle')) return; 1067 - if (isDragging) return; 1068 + if (inDragging()) return; 1068 1069 if (e.button !== 0) return; 1069 1070 pageMouseButtonDown = true; 1070 1071 ··· 1096 1097 } 1097 1098 1098 1099 // After hold fired (cursor is 'grab'): movement starts drag 1099 - if (holdDragReady && !isDragging) { 1100 + if (holdDragReady && !inDragging()) { 1100 1101 holdDragReady = false; 1101 1102 startDrag(holdDragStartScreenX, holdDragStartScreenY); 1102 1103 } ··· 1198 1199 cancelWebviewHold(); 1199 1200 // If drag was active, fully end it — webview mouseup won't bubble to document, 1200 1201 // so we must clean up here to avoid the drag overlay staying active and blocking clicks. 1201 - if (isDragging) { 1202 - isDragging = false; 1202 + if (inDragging()) { 1203 1203 pageMouseButtonDown = false; 1204 1204 dragOverlay.classList.remove('active'); 1205 1205 document.body.style.cursor = ''; ··· 1218 1218 if (isNaN(screenX) || isNaN(screenY)) return; 1219 1219 1220 1220 // If actively dragging, update window position from webview mouse coords. 1221 - if (isDragging && !isMaximized) { 1221 + if (inDragging() && !inMaximized()) { 1222 1222 const dx = screenX - dragStartScreenX; 1223 1223 const dy = screenY - dragStartScreenY; 1224 1224 screenBounds.x = dragStartBoundsX + dx; ··· 1317 1317 // --- Custom resize (all corner handles) --- 1318 1318 // Uses pointer capture. On each pointermove, calls setBounds() to resize the 1319 1319 // BrowserWindow AND updates element positions within the window. 1320 + // Lifecycle state lives in the FSM (RESIZING); local vars below are only 1321 + // the per-drag attribute state (cursor, start positions, active handle). 1320 1322 1321 - let isResizing = false; 1322 1323 let resizeDir = null; 1323 1324 let resizeStartX = 0; 1324 1325 let resizeStartY = 0; ··· 1333 1334 1334 1335 handle.addEventListener('pointerdown', (e) => { 1335 1336 if (e.button !== 0) return; 1336 - isResizing = true; 1337 1337 resizeDir = handle.dataset.dir; 1338 1338 resizeStartX = e.screenX; 1339 1339 resizeStartY = e.screenY; ··· 1350 1350 }); 1351 1351 1352 1352 handle.addEventListener('pointermove', (e) => { 1353 - if (!isResizing || activeResizeHandle !== handle || isMaximized) return; 1353 + if (!inResizing() || activeResizeHandle !== handle || inMaximized()) return; 1354 1354 if (e.buttons === 0) { 1355 1355 cancelResize(); 1356 1356 return; ··· 1387 1387 }); 1388 1388 1389 1389 handle.addEventListener('pointerup', (e) => { 1390 - if (isResizing && activeResizeHandle === handle) { 1390 + if (inResizing() && activeResizeHandle === handle) { 1391 1391 cancelResize(); 1392 1392 handle.releasePointerCapture(e.pointerId); 1393 1393 } 1394 1394 }); 1395 1395 1396 1396 handle.addEventListener('pointercancel', () => { 1397 - if (isResizing && activeResizeHandle === handle) { 1397 + if (inResizing() && activeResizeHandle === handle) { 1398 1398 cancelResize(); 1399 1399 } 1400 1400 }); ··· 1403 1403 Object.values(resizeHandles).forEach(initResizeHandle); 1404 1404 1405 1405 function cancelResize() { 1406 - const wasResizing = isResizing; 1407 - isResizing = false; 1406 + // Capture transition decision BEFORE dispatch (which mutates fsmState). 1407 + const wasResizing = inResizing(); 1408 1408 resizeDir = null; 1409 1409 activeResizeHandle = null; 1410 1410 document.body.style.cursor = ''; ··· 1418 1418 // Drag now moves the BrowserWindow via setBounds() IPC 1419 1419 1420 1420 document.addEventListener('mousemove', (e) => { 1421 - if (isDragging && !pageMouseButtonDown) { 1422 - isDragging = false; 1421 + if (inDragging() && !pageMouseButtonDown) { 1423 1422 dragOverlay.classList.remove('active'); 1424 1423 document.body.style.cursor = ''; 1425 1424 navbar.style.cursor = 'grab'; ··· 1428 1427 return; 1429 1428 } 1430 1429 1431 - if (isDragging && !isMaximized) { 1430 + if (inDragging() && !inMaximized()) { 1432 1431 const dx = e.screenX - dragStartScreenX; 1433 1432 const dy = e.screenY - dragStartScreenY; 1434 1433 screenBounds.x = dragStartBoundsX + dx; ··· 1447 1446 }); 1448 1447 1449 1448 document.addEventListener('mouseup', () => { 1450 - if (isDragging) { 1451 - isDragging = false; 1449 + if (inDragging()) { 1452 1450 dragOverlay.classList.remove('active'); 1453 1451 document.body.style.cursor = ''; 1454 1452 navbar.style.cursor = 'grab'; ··· 1540 1538 } 1541 1539 1542 1540 async function hide() { 1543 - if (isDragging || holdDragPending) return; 1541 + if (inDragging() || holdDragPending) return; 1544 1542 // Don't hide while page is loading — navbar stays visible with spinner 1545 1543 if (loadingLifecycle.state === 'loading') return; 1546 1544 if (hideTimer) {
+4 -1
docs/tasks.md
··· 10 10 11 11 ## State machines 12 12 13 - - [ ] **Page-host FSM — Phase 3: replace flag reads with FSM queries.** Phases 1 + 2 shipped 2026-04-27. The FSM now tracks all 6 states (INITIALIZING / NORMAL / MAXIMIZED / DRAGGING / RESIZING / DRAGGING_OUT_OF_MAXIMIZED) and page.js dispatches transition events at every entry/exit point. But `isDragging` / `isResizing` / `isMaximized` are still the source of truth — the FSM is shadow state, kept in sync via dispatch + idempotent applyFsmEffect. Phase 3: replace the flag reads (16 sites for `isDragging`, 5 for `isResizing`, 17 for `isMaximized`) with `fsmState === STATES.X` queries; delete the flags. Pre-maximize bounds (`preMaximizeBounds`, `preMaximizeWindowBounds`, `dragOutOfMaximizeWindowSize`) move into FSM context (the design doc's "small attribute bag"). The Playwright `Page Host FSM` spec already enforces the round-trips so any drift surfaces immediately. 13 + - [x] **Page-host FSM — Phase 3: replace flag reads with FSM queries.** Shipped 2026-04-27. All 16 `isDragging`, 5 `isResizing`, and 17 `isMaximized` reads converted to `inDragging()` / `inResizing()` / `inMaximized()` predicate helpers (which read `fsmState` directly); flag declarations + inline writes deleted. `applyFsmEffect.SET_BODY_MAXIMIZED` no longer mirrors state to a shadow var. `toggleMaximize` and `startDrag` restructured so the FSM dispatch happens BEFORE `updatePositions`/`updateUrlParams`/`setBounds`, with pre-dispatch state captured via `wasMaximized = inMaximized()` where the post-dispatch branches need it. Pre-maximize bounds (`preMaximizeBounds`, `preMaximizeWindowBounds`, `dragOutOfMaximizeWindowSize`) remain module-scoped — moving them into FSM context is deferred (would require an attribute bag in the FSM module + larger refactor; current form is correct + tested). 14 + * Test results: unit 628/628; page-host-fsm 5/5; session-restore-page-host 2/2; reopen-closed-window 5/5; page-load-failure 5/5. 14 15 15 16 --- 16 17 ··· 21 22 --- 22 23 23 24 ## Bugs 25 + 26 + - [ ] **Web permission requests (geolocation, camera, mic, notifications, clipboard) need user-approval UI.** Today Chromium permission requests from webview guests are either silently allowed or denied with no Peek-native prompt. Wire `session.setPermissionRequestHandler` (and `setPermissionCheckHandler` for sync checks) on the page-host webview's session to capture each request, surface a Peek-branded approve/reject prompt in the page UI (origin + permission name + remember-this-decision toggle), and persist per-origin decisions. Cover at minimum: geolocation, mediaDevices (camera/mic), notifications, clipboard-read/-write, midi, screen-share. Decisions should be queryable + revocable from settings. 24 27 25 28 - [ ] **Server-not-found / page-load failure shows blank white page forever.** User-reported 2026-04-27 with `http://www.metikmusic.com/` (DNS resolves but server returns nothing useful, or DNS fails). The page-host webview just sits on white with no feedback. We should handle the full lifecycle of a failed page open: DNS failure, connection refused, TLS errors, HTTP 4xx/5xx, hung loads, ERR_NAME_NOT_RESOLVED, ERR_INTERNET_DISCONNECTED. Show a Peek-branded error UI inside the canvas with: the URL that failed, the underlying error reason, an obvious Retry button, and a "Go back" / "Close" affordance. Likely hooks: `did-fail-load` and `did-fail-provisional-load` on the webview's webContents in `app/page/page.js`, plus `certificate-error` on the session. Audit other entry points too (cmd web search, external URL handler, address bar) to ensure they all funnel into the same error UI. 26 29