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 1, maximize) + round-2 polish

Two themes bundled because they share machinery (window:removed event,
test-only main-process helpers, the maximize URL param pipeline):

──── 1. Page-host FSM, Phase 1 ────────────────────────────────────────

Fixes user-reported regression: page hosts restored from a session lost
their maximize state — body.maximized class was gone, URL params
dropped maximized=1, resize handles re-appeared. JS thought it was
NORMAL while the OS window was still sized to the work area. Root
cause was the absence of a single source of truth for "what state is
this window in"; module-scoped `let` flags (`isMaximized`, `isDragging`,
…) drifted from the DOM, the URL, and the OS window.

Design doc: docs/page-host-state-machine.md (mirrors
docs/cmd-state-machine.md shape — pure module + runtime adapter).

* app/page/page-fsm.js — pure FSM. States INITIALIZING / NORMAL /
MAXIMIZED. Events INIT_COMPLETE / TOGGLE_MAXIMIZE. Effect
descriptors for body class / URL param / handle visibility /
pre-maximize capture/restore. No DOM, no IPC.

* tests/unit/page-fsm.test.js — 15/15 covering every legal edge,
illegal-transition warnings, end-to-end runs.

* page.js — `dispatchFsm` / `applyFsmEffect` adapter; init dispatches
INIT_COMPLETE with the URL `maximized` hint so a restored
maximized page-host transitions straight into MAXIMIZED.
`toggleMaximize` dispatches to keep `fsmState` in sync (effects
are idempotent against state it already mutates). DRAGGING /
RESIZING / DRAGGING_OUT_OF_MAXIMIZED stay in page.js for now —
tracked as Phase 2 in tasks.md.

* 4 propagation pinch points so the maximize hint actually reaches
INIT_COMPLETE on restore / undo-close:
- updateUrlParams() writes maximized=1 (already present from
the round-2 work; FSM just consumes it now)
- session-save reads URL param into WindowDescriptor.params.
maximized (sanitizer carries booleans through)
- undo-close ClosedWindowEntry gains a `maximized` field;
reopenLastClosedWindow forwards it to options.maximized
- window-open reads options.maximized and (a) snaps the
BrowserWindow to the active display's work area instead of
adding canvas margins (saved bounds for a maximized window are
already work-area sized — re-adding margins would overshoot),
(b) propagates maximized=1 into the new page-host URL params

* Test-bypass helpers in saveSessionSnapshot/restoreSessionSnapshot:
`_forceForTest` skips the isTestProfile() + isVisible() guards so
a Playwright spec can drive save+restore directly. Exposed via
__peek_test.forceSaveSession / forceRestoreSession in entry.ts.

* tests/desktop/session-restore-page-host.spec.ts — 2 tests.
Non-maximized round-trip: bounds preserved through save+restore.
Maximized round-trip (the regression repro): body class, URL
param, and handle visibility all survive. Both deterministic via
`window:removed` pubsub + `__pageModuleReady` flag.

──── 2. Round-2 polish from user testing ──────────────────────────────

* Undo-close window inflated each cycle ("wayyy wider").
main.ts `closed` handler computed saveBounds by subtracting fixed
canvas chrome from OS window bounds, but side-panel extraWidth
(entities/notes/etc) stayed in the saved width. On reopen that
inflated value became the new WEBVIEW size, panels re-expanded
the window, and each cycle compounded. Fixed by reading the page-
host URL params at close time — those reflect screenBounds (webview
only). Maximized case falls back to the fixed-margin subtraction
so reopen doesn't overshoot the display. New regression test in
tests/desktop/reopen-closed-window.spec.ts via
__peek_test.getHostUrlParamsByUrl.

* Lists tag rows broken card.
`getItemDisplayInfo` in app/lib/card-helpers.js had no branch for
itemType === 'tag' — tag results rendered with undefined title +
the question-mark fallback icon, and the trash-can would have
called datastore.deleteItem against a tag id. Added the `tag`
branch (title from item.title, subtitle "Tag (used N×)", `#`
icon) and suppressed onDelete for tag rows in features/lists/
home.js. New unit tests in card-helpers-favicon.test.js.

* Session-restore parity test made deterministic.
Added `window:removed` pubsub event in main.ts — published AFTER
`windowRegistry.delete` (the authoritative post-cleanup signal,
distinct from `window:closed` which fires earlier). The page-load
spec's session-restore parity test now subscribes to
window:removed via the Promise pattern; no sleeps.

──── Test results ────────────────────────────────────────────────────

* unit: 610/610 (was 595 + 15 new page-fsm tests + 2 card-helpers tag
tests, but card-helpers tests also added; ends at 610)
* tests/desktop/session-restore-page-host.spec.ts: 2/2 (new)
* tests/desktop/reopen-closed-window.spec.ts: 5/5 (added 1 regression)
* tests/desktop/page-load-failure.spec.ts: 5/5 (deterministic restore)

+1139 -28
+6
app/lib/card-helpers.js
··· 14 14 const TYPE_ICONS = { 15 15 url: GLOBE_FAVICON, 16 16 text: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F4DD}</text></svg>', 17 + tag: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">#</text></svg>', 17 18 tagset: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F3F7}\uFE0F</text></svg>', 18 19 image: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F5BC}\uFE0F</text></svg>', 19 20 entity: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F48E}</text></svg>', ··· 236 237 title = item.title || (item.content || '').substring(0, 100) + ((item.content || '').length > 100 ? '...' : ''); 237 238 subtitle = noteUrl || ''; 238 239 faviconUrl = TYPE_ICONS.text; 240 + } else if (itemType === 'tag') { 241 + title = item.title || item.name || 'Tag'; 242 + const freq = item.frequency || 0; 243 + subtitle = `Tag${freq ? ` (used ${freq}×)` : ''}`; 244 + faviconUrl = TYPE_ICONS.tag; 239 245 } else if (itemType === 'tagset') { 240 246 title = 'Tag Set'; 241 247 subtitle = tags.length > 0 ? tags.map(t => t.name).join(', ') : 'Empty tagset';
+149
app/page/page-fsm.js
··· 1 + /** 2 + * page-fsm.js — pure state machine for the page-host renderer. 3 + * 4 + * No DOM, no IPC, no api.* calls. The FSM owns the lifecycle state + 5 + * derives the side-effects each transition should produce; the runtime 6 + * adapter in `page.js` is the only thing that touches the DOM, calls 7 + * setBounds, or writes URL params. 8 + * 9 + * Today's scope: just the NORMAL ↔ MAXIMIZED lifecycle, because that's 10 + * the surface of the user-reported regression (restored maximized 11 + * windows losing their maximize state). DRAGGING / RESIZING / 12 + * DRAGGING_OUT_OF_MAXIMIZED states from the design doc are not yet 13 + * promoted into the FSM — page.js still owns those flags. Folding them 14 + * in is a follow-up; the test for that lives in 15 + * docs/page-host-state-machine.md. 16 + * 17 + * See: docs/page-host-state-machine.md, docs/cmd-state-machine.md 18 + */ 19 + 20 + export const STATES = Object.freeze({ 21 + INITIALIZING: 'INITIALIZING', 22 + NORMAL: 'NORMAL', 23 + MAXIMIZED: 'MAXIMIZED', 24 + }); 25 + 26 + export const EVENTS = Object.freeze({ 27 + /** Page-host module finished evaluating; URL inputs parsed. */ 28 + INIT_COMPLETE: 'INIT_COMPLETE', 29 + /** User asked to toggle maximize (cmd, navbar dblclick, pubsub). */ 30 + TOGGLE_MAXIMIZE: 'TOGGLE_MAXIMIZE', 31 + }); 32 + 33 + /** Initial FSM state. */ 34 + export const INITIAL_STATE = STATES.INITIALIZING; 35 + 36 + /** 37 + * Effect descriptors emitted by transitions. The runtime adapter 38 + * pattern-matches on `type` and applies them. 39 + */ 40 + export const EFFECTS = Object.freeze({ 41 + /** Add or remove the `maximized` class on document.body. */ 42 + SET_BODY_MAXIMIZED: 'SET_BODY_MAXIMIZED', 43 + /** Add or remove the `maximized=1` URL param. */ 44 + SET_URL_MAXIMIZED: 'SET_URL_MAXIMIZED', 45 + /** Hide or show the resize handles + show/hide the maximized layout. */ 46 + SET_HANDLES_VISIBLE: 'SET_HANDLES_VISIBLE', 47 + /** Capture pre-maximize screen + window bounds before transitioning. */ 48 + CAPTURE_PRE_MAXIMIZE: 'CAPTURE_PRE_MAXIMIZE', 49 + /** Restore screen + window bounds to the captured pre-maximize state. */ 50 + RESTORE_PRE_MAXIMIZE: 'RESTORE_PRE_MAXIMIZE', 51 + /** Set screen bounds to the display work area (maximize). */ 52 + ENTER_MAXIMIZED_LAYOUT: 'ENTER_MAXIMIZED_LAYOUT', 53 + }); 54 + 55 + const e = (type, payload = {}) => ({ type, ...payload }); 56 + 57 + /** 58 + * Transition the FSM. 59 + * 60 + * @param {string} state Current FSM state. 61 + * @param {{ type: string, [key: string]: any }} event 62 + * @returns {{ state: string, effects: Array<object>, warning?: string }} 63 + * New state and the side effects the runtime should apply, in order. 64 + */ 65 + export function transition(state, event) { 66 + if (!event || typeof event.type !== 'string') { 67 + return { state, effects: [], warning: `transition: malformed event ${JSON.stringify(event)}` }; 68 + } 69 + 70 + switch (state) { 71 + case STATES.INITIALIZING: { 72 + if (event.type === EVENTS.INIT_COMPLETE) { 73 + // Init reads the page-host URL param `maximized`. If present, 74 + // we boot directly into MAXIMIZED — window-open has already 75 + // sized the BrowserWindow to the work area for us, so there's 76 + // no setBounds work to do. Otherwise enter NORMAL. 77 + if (event.maximized === true) { 78 + return { 79 + state: STATES.MAXIMIZED, 80 + effects: [ 81 + e(EFFECTS.SET_BODY_MAXIMIZED, { value: true }), 82 + e(EFFECTS.SET_HANDLES_VISIBLE, { value: false }), 83 + // Note: no ENTER_MAXIMIZED_LAYOUT here — init's screenBounds 84 + // already came in as workArea (the saved URL params reflect 85 + // the maximized screenBounds). No setBounds either. 86 + ], 87 + }; 88 + } 89 + return { state: STATES.NORMAL, effects: [] }; 90 + } 91 + return { state, effects: [], warning: `Illegal event ${event.type} in ${state}` }; 92 + } 93 + 94 + case STATES.NORMAL: { 95 + if (event.type === EVENTS.TOGGLE_MAXIMIZE) { 96 + return { 97 + state: STATES.MAXIMIZED, 98 + effects: [ 99 + e(EFFECTS.CAPTURE_PRE_MAXIMIZE), 100 + e(EFFECTS.ENTER_MAXIMIZED_LAYOUT), 101 + e(EFFECTS.SET_BODY_MAXIMIZED, { value: true }), 102 + e(EFFECTS.SET_HANDLES_VISIBLE, { value: false }), 103 + e(EFFECTS.SET_URL_MAXIMIZED, { value: true }), 104 + ], 105 + }; 106 + } 107 + if (event.type === EVENTS.INIT_COMPLETE) { 108 + return { state, effects: [], warning: 'Duplicate INIT_COMPLETE in NORMAL' }; 109 + } 110 + return { state, effects: [], warning: `Illegal event ${event.type} in ${state}` }; 111 + } 112 + 113 + case STATES.MAXIMIZED: { 114 + if (event.type === EVENTS.TOGGLE_MAXIMIZE) { 115 + return { 116 + state: STATES.NORMAL, 117 + effects: [ 118 + e(EFFECTS.RESTORE_PRE_MAXIMIZE), 119 + e(EFFECTS.SET_BODY_MAXIMIZED, { value: false }), 120 + e(EFFECTS.SET_HANDLES_VISIBLE, { value: true }), 121 + e(EFFECTS.SET_URL_MAXIMIZED, { value: false }), 122 + ], 123 + }; 124 + } 125 + if (event.type === EVENTS.INIT_COMPLETE) { 126 + return { state, effects: [], warning: 'Duplicate INIT_COMPLETE in MAXIMIZED' }; 127 + } 128 + return { state, effects: [], warning: `Illegal event ${event.type} in ${state}` }; 129 + } 130 + 131 + default: 132 + return { state, effects: [], warning: `Unknown state ${state}` }; 133 + } 134 + } 135 + 136 + /** 137 + * Convenience: thread a sequence of events through the FSM and return 138 + * the final state + collected effects. Useful for test setup. 139 + */ 140 + export function run(events, initial = INITIAL_STATE) { 141 + let state = initial; 142 + const effects = []; 143 + for (const event of events) { 144 + const next = transition(state, event); 145 + state = next.state; 146 + effects.push(...next.effects); 147 + } 148 + return { state, effects }; 149 + }
+89 -2
app/page/page.js
··· 20 20 import api from '../api.js'; 21 21 import { createPageWidgetHost } from './page-widgets.js'; 22 22 import { PageNotesPane } from './page-notes.js'; 23 + import { 24 + STATES as FSM_STATES, EVENTS as FSM_EVENTS, EFFECTS as FSM_EFFECTS, 25 + INITIAL_STATE as FSM_INITIAL_STATE, transition as fsmTransition, 26 + } from './page-fsm.js'; 23 27 24 28 console.log('[page] Script loaded'); 25 29 ··· 120 124 const initialHeight = parseInt(params.get('height')) || 600; 121 125 const restoreScrollX = parseInt(params.get('scrollX')) || 0; 122 126 const restoreScrollY = parseInt(params.get('scrollY')) || 0; 127 + // Restored-from-session / undo-close hint. When true, the BrowserWindow 128 + // is already sized to the work area and screenBounds (= initial*) reflect 129 + // the maximized layout — so the page-host FSM boots straight into 130 + // MAXIMIZED via INIT_COMPLETE without needing to call setBounds. 131 + const initialMaximized = params.get('maximized') === '1'; 123 132 124 133 if (!targetUrl) { 125 134 console.error('[page] No URL provided'); ··· 140 149 }; 141 150 142 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. 155 + let fsmState = FSM_INITIAL_STATE; 143 156 let isMaximized = false; 144 157 let preMaximizeBounds = null; // { x, y, width, height } screen bounds before maximize 145 158 // Raw WINDOW bounds captured before maximize (used to restore to the exact same ··· 148 161 // canvas margin adjustment in ipc.ts window-open is skipped). 149 162 let preMaximizeWindowBounds = null; 150 163 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. 168 + function applyFsmEffect(effect) { 169 + switch (effect.type) { 170 + case FSM_EFFECTS.SET_BODY_MAXIMIZED: 171 + if (effect.value) document.body.classList.add('maximized'); 172 + else document.body.classList.remove('maximized'); 173 + isMaximized = effect.value; 174 + return; 175 + case FSM_EFFECTS.SET_HANDLES_VISIBLE: 176 + // No DOM write — handle visibility is set by updatePositions() based 177 + // on isMaximized. Run updatePositions to apply. 178 + updatePositions(); 179 + return; 180 + case FSM_EFFECTS.SET_URL_MAXIMIZED: 181 + // updateUrlParams() already reflects isMaximized, so just call it. 182 + updateUrlParams(); 183 + return; 184 + case FSM_EFFECTS.CAPTURE_PRE_MAXIMIZE: 185 + case FSM_EFFECTS.RESTORE_PRE_MAXIMIZE: 186 + case FSM_EFFECTS.ENTER_MAXIMIZED_LAYOUT: 187 + // toggleMaximize handles these directly today; the FSM emits them 188 + // for future migration. Ignore for now. 189 + return; 190 + default: 191 + console.warn('[page-fsm] Unknown effect:', effect); 192 + } 193 + } 194 + 195 + function dispatchFsm(event) { 196 + const next = fsmTransition(fsmState, event); 197 + if (next.warning) { 198 + console.warn('[page-fsm]', next.warning, '(state:', fsmState, ', event:', event, ')'); 199 + } 200 + fsmState = next.state; 201 + for (const eff of next.effects) applyFsmEffect(eff); 202 + } 203 + 151 204 // NOTE: The window always includes navbar space (NAVBAR_HEIGHT) to avoid 152 205 // visual jumps on show/hide. Navbar visibility is CSS-only — no window resize. 153 206 ··· 262 315 url.searchParams.set('y', String(Math.round(screenBounds.y))); 263 316 url.searchParams.set('width', String(Math.round(screenBounds.width))); 264 317 url.searchParams.set('height', String(Math.round(screenBounds.height))); 318 + // Carry the maximized flag so undo-close (main.ts) can fall back to the 319 + // raw OS window bounds — when maximized, screenBounds equals the work 320 + // area (window == webview) and re-adding canvas margins on reopen would 321 + // overshoot the display. 322 + if (isMaximized) { 323 + url.searchParams.set('maximized', '1'); 324 + } else { 325 + url.searchParams.delete('maximized'); 326 + } 265 327 history.replaceState(null, '', url.toString()); 266 328 } catch (e) { 267 329 DEBUG && console.log('[page] Failed to update URL params:', e); ··· 546 608 } 547 609 centerColumn.style.opacity = '1'; 548 610 updateUrlParams(); 611 + // Sync FSM (effects are idempotent against state we already mutated). 612 + dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 549 613 return; 550 614 } 551 615 ··· 596 660 await api.window.setBounds(computeWindowBounds(screenBounds)); 597 661 centerColumn.style.opacity = '1'; 598 662 updateUrlParams(); 663 + // Sync FSM (effects are idempotent against state we already mutated). 664 + dispatchFsm({ type: FSM_EVENTS.TOGGLE_MAXIMIZE }); 599 665 } catch (err) { 600 666 console.error('[page] toggleMaximize failed:', err); 601 667 } ··· 634 700 635 701 // Initial positioning 636 702 updatePositions(); 703 + 704 + // FSM init. Dispatch AFTER updatePositions so the runtime adapter's 705 + // effects (which themselves call updatePositions/updateUrlParams) run 706 + // against an initialized DOM. If `maximized=1` came in via URL params 707 + // (session restore or undo-close), this transitions the FSM straight 708 + // into MAXIMIZED — body class on, handles hidden, URL param preserved 709 + // — without needing to call setBounds (the BrowserWindow already came 710 + // in sized to the work area). See docs/page-host-state-machine.md. 711 + dispatchFsm({ type: FSM_EVENTS.INIT_COMPLETE, maximized: initialMaximized }); 637 712 638 713 // --- Set up webview partition and load URL --- 639 714 ··· 811 886 // B fails → webview shows A again → meta refresh fires again → infinite loop. 812 887 if (lastFailedNavUrl && e.url === lastFailedNavUrl) { 813 888 console.log('[page] Skipping loading for previously failed URL:', e.url); 889 + return; 890 + } 891 + // After a load failure, Chromium auto-navigates to its internal 892 + // chrome-error://chromewebdata/ page. Without this guard, did-start-navigation 893 + // re-arms loadingLifecycle, the chrome-error page loads silently (no 894 + // matching did-finish-load that we listen for in some cold-start scenarios, 895 + // notably session restore of a previously-failed URL), and the navbar 896 + // glow stays on forever while the user stares at a blank page. 897 + if (e.url && e.url.startsWith('chrome-error://')) { 898 + console.log('[page] Skipping loading state for chrome-error navigation:', e.url); 814 899 return; 815 900 } 816 901 // Clear the failed URL tracker on any new (different) navigation ··· 1947 2032 // 1948 2033 // Fix: only clear loading if the aborted URL is still the latest navigation. 1949 2034 // If a newer navigation has started (redirect target), let it keep loading. 1950 - if (latestNavigationUrl && e.url !== latestNavigationUrl) { 1951 - console.log('[page] Load aborted (error -3) for', e.url, '— redirect in progress to', latestNavigationUrl, ', keeping loading state'); 2035 + // (Webview did-fail-load uses `validatedURL`; `e.url` is undefined.) 2036 + const abortedUrl = e.validatedURL || e.url || ''; 2037 + if (latestNavigationUrl && abortedUrl && abortedUrl !== latestNavigationUrl) { 2038 + console.log('[page] Load aborted (error -3) for', abortedUrl, '— redirect in progress to', latestNavigationUrl, ', keeping loading state'); 1952 2039 return; 1953 2040 } 1954 2041 console.log('[page] Load aborted (error -3), clearing loading state');
+25
backend/electron/entry.ts
··· 136 136 reopenLastClosedWindow, 137 137 getClosedWindowStack, 138 138 getClosedWindowCount, 139 + /** Force-save current session snapshot (bypasses test-profile + isVisible guards). */ 140 + forceSaveSession: () => saveSessionSnapshot('manual', { _forceForTest: true }), 141 + /** Force-restore session snapshot (bypasses test-profile guard). */ 142 + forceRestoreSession: () => restoreSessionSnapshot({ restoreSession: true }, undefined, true), 143 + /** Returns the page-host URL-param bounds (the canonical webview screenBounds). */ 144 + getHostUrlParamsByUrl: (urlSubstr: string) => { 145 + let last: { x: number; y: number; width: number; height: number } | null = null; 146 + for (const w of BrowserWindow.getAllWindows()) { 147 + if (w.isDestroyed()) continue; 148 + try { 149 + const u = w.webContents.getURL(); 150 + if (!u.includes(urlSubstr)) continue; 151 + if (!u.startsWith('peek://app/page/')) continue; 152 + const parsed = new URL(u); 153 + const x = parseInt(parsed.searchParams.get('x') || ''); 154 + const y = parseInt(parsed.searchParams.get('y') || ''); 155 + const width = parseInt(parsed.searchParams.get('width') || ''); 156 + const height = parseInt(parsed.searchParams.get('height') || ''); 157 + if ([x, y, width, height].every(Number.isFinite)) { 158 + last = { x, y, width, height }; 159 + } 160 + } catch { /* ignore */ } 161 + } 162 + return last; 163 + }, 139 164 }; 140 165 (globalThis as any).__peek_electron = { globalShortcut }; 141 166
+36 -11
backend/electron/ipc.ts
··· 426 426 if (entry.scrollX != null) options.scrollX = entry.scrollX; 427 427 if (entry.scrollY != null) options.scrollY = entry.scrollY; 428 428 429 + // Restore maximize state so the page-host FSM boots into MAXIMIZED. 430 + if (entry.maximized) options.maximized = true; 431 + 429 432 // Publish event for the background window to pick up and open 430 433 publish(getSystemAddress(), 'window:reopen-request', { 431 434 url: entry.url, ··· 794 797 winOptions.y = wa.y + Math.round((wa.height - winH) / 2); 795 798 } 796 799 797 - // winOptions.x/y/width/height are currently the webview screen coordinates 798 - canvasWebviewBounds = { 799 - x: winOptions.x as number, 800 - y: winOptions.y as number, 801 - width: winOptions.width as number, 802 - height: winOptions.height as number, 803 - }; 804 - winOptions.x = canvasWebviewBounds.x - CANVAS_MARGIN; 805 - winOptions.y = canvasWebviewBounds.y - CANVAS_TRIGGER_ZONE - CANVAS_NAVBAR_HEIGHT; 806 - winOptions.width = canvasWebviewBounds.width + CANVAS_MARGIN * 2; 807 - winOptions.height = canvasWebviewBounds.height + CANVAS_TRIGGER_ZONE + CANVAS_MARGIN + CANVAS_NAVBAR_HEIGHT; 800 + // Maximize hint: the saved bounds for a maximized window are the OS 801 + // window bounds (= work area), NOT the webview bounds — re-adding 802 + // canvas margins would push the window past the work area edge. 803 + // Snap directly to the active display's work area instead. The 804 + // page-host FSM will boot into MAXIMIZED via INIT_COMPLETE 805 + // (maximized=1 also propagates to pageParams below). 806 + // See docs/page-host-state-machine.md. 807 + if (options.maximized === true) { 808 + const display = screen.getDisplayNearestPoint({ x: winOptions.x as number, y: winOptions.y as number }); 809 + const wa = display.workArea; 810 + canvasWebviewBounds = { x: wa.x, y: wa.y, width: wa.width, height: wa.height }; 811 + winOptions.x = wa.x; 812 + winOptions.y = wa.y; 813 + winOptions.width = wa.width; 814 + winOptions.height = wa.height; 815 + } else { 816 + // winOptions.x/y/width/height are currently the webview screen coordinates 817 + canvasWebviewBounds = { 818 + x: winOptions.x as number, 819 + y: winOptions.y as number, 820 + width: winOptions.width as number, 821 + height: winOptions.height as number, 822 + }; 823 + winOptions.x = canvasWebviewBounds.x - CANVAS_MARGIN; 824 + winOptions.y = canvasWebviewBounds.y - CANVAS_TRIGGER_ZONE - CANVAS_NAVBAR_HEIGHT; 825 + winOptions.width = canvasWebviewBounds.width + CANVAS_MARGIN * 2; 826 + winOptions.height = canvasWebviewBounds.height + CANVAS_TRIGGER_ZONE + CANVAS_MARGIN + CANVAS_NAVBAR_HEIGHT; 827 + } 808 828 } 809 829 810 830 DEBUG && console.log('Creating window with options:', winOptions); ··· 963 983 // Pass scroll position for undo-close-window restoration 964 984 if (options.scrollX != null) pageParams.set('scrollX', String(options.scrollX)); 965 985 if (options.scrollY != null) pageParams.set('scrollY', String(options.scrollY)); 986 + // Propagate the maximize hint so the page-host FSM can boot directly 987 + // into MAXIMIZED — without this, session restore (and undo-close) 988 + // would lose the body class / handles / URL param sync. 989 + // See docs/page-host-state-machine.md. 990 + if (options.maximized === true) pageParams.set('maximized', '1'); 966 991 loadUrl = `peek://app/page/index.html?${pageParams.toString()}`; 967 992 DEBUG && console.log('Routing web page through peek://app/page:', url, '->', loadUrl); 968 993
+59 -4
backend/electron/main.ts
··· 99 99 role?: string; // Window role (workspace, content, etc.) — needed to restore peek:// windows correctly 100 100 scrollX?: number; // Scroll position at time of close 101 101 scrollY?: number; 102 + maximized?: boolean; // Page-host was maximized at close — restore directly into MAXIMIZED on reopen 102 103 timestamp: number; 103 104 } 104 105 ··· 382 383 app.on('browser-window-created', (_, window) => { 383 384 const windowId = window.id; 384 385 385 - // Capture window bounds before the window is destroyed (for reopen-last-closed) 386 + // Capture window bounds + page-host URL before the window is destroyed 387 + // (for reopen-last-closed). The page-host keeps URL params in sync with 388 + // its true webview screenBounds (see app/page/page.js updateUrlParams) — 389 + // those are the authoritative size for the next open, since window 390 + // bounds also include side-panel extraWidth (entities/notes/etc). 386 391 let lastBounds: { x: number; y: number; width: number; height: number } | null = null; 392 + let lastHostUrl: string | null = null; 387 393 window.on('close', () => { 388 394 try { 389 395 if (!window.isDestroyed()) { 390 396 lastBounds = window.getBounds(); 397 + lastHostUrl = window.webContents.getURL(); 391 398 } 392 399 } catch { 393 400 // Ignore errors during shutdown ··· 523 530 // Context may be cleaned up already during shutdown 524 531 } 525 532 526 - // For canvas pages, convert window bounds back to webview bounds 527 - // (window-open will re-add canvas margins when reopening) 533 + // For canvas pages, the page-host URL params are the authoritative 534 + // webview screen bounds — they exclude side-panel extraWidth (which 535 + // varies with whether entities/notes/etc are open). Falling back to 536 + // window-bounds-minus-fixed-margins inflated saveBounds whenever a 537 + // panel was open, so reopen produced a wider window each cycle. 538 + // 539 + // Exception: when the page-host is in maximized mode, screenBounds 540 + // equals the display work area (window == webview, no canvas chrome 541 + // applied) — using URL params here would cause reopen's canvas 542 + // margin add to overshoot the display. Fall back to fixed-margin 543 + // subtraction in that case so the round-trip is exact. 528 544 let saveBounds = lastBounds; 529 - if (lastBounds && isWebUrl) { 545 + let saveMaximized = false; 546 + if (isWebUrl && lastHostUrl && lastHostUrl.startsWith('peek://app/page/')) { 547 + try { 548 + const parsed = new URL(lastHostUrl); 549 + saveMaximized = parsed.searchParams.get('maximized') === '1'; 550 + if (!saveMaximized) { 551 + const px = parseInt(parsed.searchParams.get('x') || ''); 552 + const py = parseInt(parsed.searchParams.get('y') || ''); 553 + const pw = parseInt(parsed.searchParams.get('width') || ''); 554 + const ph = parseInt(parsed.searchParams.get('height') || ''); 555 + if (Number.isFinite(px) && Number.isFinite(py) && Number.isFinite(pw) && Number.isFinite(ph)) { 556 + saveBounds = { x: px, y: py, width: pw, height: ph }; 557 + } 558 + } 559 + } catch { 560 + // URL parse failed — fall through to bounds fallback below 561 + } 562 + } 563 + // Fallback when no URL params (non-canvas, or page-host hadn't 564 + // updated params yet, or the window was maximized): subtract the 565 + // fixed canvas chrome from window bounds. Note: still over-counts 566 + // when panels were open in non-maximized mode, but only reached 567 + // when URL params are unavailable or unreliable. 568 + if (saveBounds === lastBounds && lastBounds && isWebUrl) { 530 569 const CANVAS_MARGIN = 8; 531 570 const CANVAS_TRIGGER_ZONE = 8; 532 571 const CANVAS_NAVBAR_HEIGHT = 36; ··· 552 591 role: role || undefined, 553 592 scrollX: scrollX || undefined, 554 593 scrollY: scrollY || undefined, 594 + maximized: saveMaximized || undefined, 555 595 timestamp: Date.now(), 556 596 }); 557 597 } ··· 559 599 } 560 600 561 601 windowRegistry.delete(windowId); 602 + // Post-delete event — the deterministic "the window is fully gone 603 + // from the registry" signal. Distinct from `window:closed`, which 604 + // fires earlier (before pushClosedWindow + delete) and so isn't a 605 + // safe gate for "you can now re-open the same URL without dedup 606 + // matching the previous instance". 607 + try { 608 + publish('system', 'window:removed', { id: windowId }); 609 + } catch (publishErr) { 610 + DEBUG && console.error('[lifecycle] window:removed publish failed:', publishErr); 611 + } 562 612 } catch (err) { 563 613 // During shutdown, errors here must not prevent the quit sequence 564 614 DEBUG && console.error('[lifecycle] Error in window closed handler:', err); 565 615 windowRegistry.delete(windowId); 616 + try { 617 + publish('system', 'window:removed', { id: windowId }); 618 + } catch { 619 + // already in error path; suppress 620 + } 566 621 } 567 622 }); 568 623
+31 -10
backend/electron/session.ts
··· 125 125 * MUST be synchronous — runs in the before-quit handler before closeDatabase(). 126 126 * Uses better-sqlite3 synchronous APIs via getDb(). 127 127 */ 128 - export function saveSessionSnapshot(reason: SessionSnapshot['reason'], opts?: { cleanShutdown?: boolean }): void { 129 - // Skip save in test environments 130 - if (isTestProfile()) return; 128 + export function saveSessionSnapshot(reason: SessionSnapshot['reason'], opts?: { cleanShutdown?: boolean; _forceForTest?: boolean }): void { 129 + // Skip save in test environments unless _forceForTest is set (used by 130 + // session-restore Playwright specs that drive save+restore directly). 131 + if (isTestProfile() && !opts?._forceForTest) return; 131 132 132 133 DEBUG && console.log(`[session] Saving session snapshot (reason: ${reason})`); 133 134 ··· 158 159 continue; 159 160 } 160 161 161 - // Skip invisible windows 162 - if (!win.isVisible()) { 162 + // Skip invisible windows (test override skips this check so headless 163 + // page-hosts — which Electron reports as not visible — can still be 164 + // saved + restored by the session-restore Playwright spec). 165 + if (!win.isVisible() && !opts?._forceForTest) { 163 166 continue; 164 167 } 165 168 ··· 201 204 const canvasBounds = extractCanvasBounds(url); 202 205 const windowBounds = canvasBounds || win.getBounds(); 203 206 204 - console.log(`[session:save] Window ${id} "${win.getTitle()}" bounds: (${windowBounds.x},${windowBounds.y}) ${windowBounds.width}x${windowBounds.height}${canvasBounds ? " [canvas]" : ""} url=${realUrl.substring(0, 80)}`); 207 + // Detect the page-host's maximized state from its URL param so restore 208 + // can re-enter MAXIMIZED on the page-host FSM rather than booting NORMAL 209 + // and leaving body class / handle visibility / URL param out of sync 210 + // (see docs/page-host-state-machine.md). 211 + let canvasMaximized = false; 212 + if (url.startsWith('peek://app/page')) { 213 + try { 214 + canvasMaximized = new URL(url).searchParams.get('maximized') === '1'; 215 + } catch { /* unparseable; treat as not maximized */ } 216 + } 217 + 218 + console.log(`[session:save] Window ${id} "${win.getTitle()}" bounds: (${windowBounds.x},${windowBounds.y}) ${windowBounds.width}x${windowBounds.height}${canvasBounds ? " [canvas]" : ""}${canvasMaximized ? " [maximized]" : ""} url=${realUrl.substring(0, 80)}`); 205 219 206 220 // Capture context state for this window 207 221 let windowContext: WindowContext | undefined; ··· 217 231 // Context not available, skip 218 232 } 219 233 234 + const sanitized = sanitizeParams(winData.params); 235 + if (canvasMaximized) { 236 + sanitized.maximized = true; 237 + } 238 + 220 239 windowDescriptors.push({ 221 240 url: realUrl, 222 241 key: (winData.params.key as string) || undefined, 223 242 title: win.getTitle(), 224 243 bounds: windowBounds, 225 244 source: winData.source, 226 - params: sanitizeParams(winData.params), 245 + params: sanitized, 227 246 zOrder: zOrderMap.get(id) ?? 0, 228 247 focused: win.isFocused(), 229 248 context: windowContext, ··· 503 522 504 523 export async function restoreSessionSnapshot( 505 524 prefs: Record<string, unknown>, 506 - crashState?: { cleanShutdown: boolean; crashCount: number } 525 + crashState?: { cleanShutdown: boolean; crashCount: number }, 526 + _forceForTest?: boolean, 507 527 ): Promise<{ restored: number; failed: number; total: number }> { 508 528 const result = { restored: 0, failed: 0, total: 0 }; 509 529 510 - // Skip restore in test environments 511 - if (isTestProfile()) { 530 + // Skip restore in test environments unless _forceForTest is set (used by 531 + // session-restore Playwright specs that drive save+restore directly). 532 + if (isTestProfile() && !_forceForTest) { 512 533 DEBUG && console.log('[session] Skipping session restore in test profile'); 513 534 return result; 514 535 }
+141
docs/page-host-state-machine.md
··· 1 + # Page-host state machine — design 2 + 3 + **Status:** draft for review 4 + **Date:** 2026-04-27 5 + **Failing repro:** `tests/desktop/session-restore-page-host.spec.ts` — _maximized page host: maximize state survives save+restore_ 6 + 7 + --- 8 + 9 + ## Why 10 + 11 + `app/page/page.js` is the canvas page-host renderer. Today it manages window state with a swarm of module-scoped `let` variables and ad-hoc setters: 12 + 13 + ``` 14 + isMaximized isDragging webviewHoldReady 15 + extraWidth pageMouseButtonDown webviewMouseDown 16 + preMaximizeBounds preMaximizeWindowBounds dragStartScreenX/Y/... 17 + pendingBoundsUpdate screenBounds ... 18 + ``` 19 + 20 + There is no single answer to _"what state is this window in right now?"_. The CSS layer reads `body.classList.contains('maximized')`, the JS layer reads `isMaximized`, the OS layer reads `BrowserWindow.getBounds()`, and session-save reads URL params — and these four can drift out of sync. The user-reported regression is exactly that drift: a maximized page-host saved and restored has `body.maximized=false`, no `maximized=1` URL param, visible resize handles, and an OS window sized to the work area. JS thinks it is windowed; everything else thinks it is something else. 21 + 22 + Additional smells the current design produces: 23 + 24 + - **Restore loses state.** `window-open` regenerates the page-host URL from scratch and drops the `maximized=1` param. 25 + - **Init does not derive state.** Page.js boots into NORMAL regardless of whether the just-loaded URL describes a maximized layout. 26 + - **Bounds math is fragile.** `computeWindowBounds` mixes `extraWidth` (panels) into the OS window, so a save based on OS bounds + fixed-margin subtraction over-counts whenever a panel is open. The recent undo-close fix worked around this by reading URL params; the restore path has the same shape and a similar fix would just be another patch on top of the same model. 27 + - **No explicit transitions.** `toggleMaximize` is the only function that flips `isMaximized` _and_ updates the body class _and_ recomputes screenBounds _and_ persists the URL param. Anything else that wants to enter maximized mode (e.g. session restore) would have to duplicate every step. 28 + 29 + The FSM model lets us encode the transitions in one place, so any code path that wants to enter MAXIMIZED takes the same edge. 30 + 31 + --- 32 + 33 + ## States 34 + 35 + ``` 36 + INITIALIZING ──▶ NORMAL ◀─────▶ MAXIMIZED 37 + │ ▲ │ ▲ 38 + │ │ │ │ 39 + ▼ │ ▼ │ 40 + DRAGGING DRAGGING_OUT_OF_MAXIMIZED 41 + │ ▲ │ 42 + │ │ │ (drag exit 43 + ▼ │ ▼ re-enters NORMAL, 44 + RESIZING never MAXIMIZED) 45 + ``` 46 + 47 + | State | Meaning | Resize handles | Body class | 48 + |-------|---------|----------------|------------| 49 + | `INITIALIZING` | Page-host module loaded; URL params parsed; reading initial state | hidden | none | 50 + | `NORMAL` | Default windowed state | visible | none | 51 + | `MAXIMIZED` | Filled to display work area; OS window == webview | hidden | `maximized` | 52 + | `DRAGGING` | Mouse-driven move from `NORMAL` | hidden during drag | `dragging` | 53 + | `RESIZING` | Pointer-captured resize from `NORMAL` | active handle visible | `resizing` | 54 + | `DRAGGING_OUT_OF_MAXIMIZED` | User dragged a maximized window — bounds restored to pre-maximize, drag continues | hidden | `dragging` | 55 + 56 + INITIALIZING is the only state with two possible exits: **NORMAL** (default) or **MAXIMIZED** (if init inputs say so — see [Restoration inputs](#restoration-inputs)). 57 + 58 + --- 59 + 60 + ## Transition table 61 + 62 + | From | Event | To | Side effects | 63 + |------|-------|-----|---------------| 64 + | `INITIALIZING` | `init.complete` (no maximize hint) | `NORMAL` | `updatePositions()` | 65 + | `INITIALIZING` | `init.complete` (maximize hint) | `MAXIMIZED` | `updatePositions()` (in maximized layout); body class set; resize handles hidden; **no setBounds** (window-open already sized the window to work area) | 66 + | `NORMAL` | `user.toggleMaximize` | `MAXIMIZED` | capture `preMaximizeBounds` + `preMaximizeWindowBounds`; set screenBounds = workArea; setBounds(workArea); body class added; URL param `maximized=1` written | 67 + | `NORMAL` | `user.dragStart` (navbar / hold) | `DRAGGING` | drag overlay shown; cursor set | 68 + | `NORMAL` | `user.resizeStart` (handle) | `RESIZING` | pointer capture; webview opacity reduced | 69 + | `MAXIMIZED` | `user.toggleMaximize` | `NORMAL` | restore `preMaximizeWindowBounds` directly; setBounds; body class removed; URL param removed | 70 + | `MAXIMIZED` | `user.dragStart` (navbar) | `DRAGGING_OUT_OF_MAXIMIZED` | restore screenBounds + window to `preMaximizeBounds` re-centered on cursor; body class removed | 71 + | `DRAGGING` | `user.dragMove` | `DRAGGING` (self-loop) | `setBounds(computeWindowBounds(screenBounds))` | 72 + | `DRAGGING` | `user.dragEnd` | `NORMAL` | drag overlay hidden; URL params updated | 73 + | `DRAGGING_OUT_OF_MAXIMIZED` | `user.dragMove` | `DRAGGING_OUT_OF_MAXIMIZED` (self-loop) | same as `DRAGGING` | 74 + | `DRAGGING_OUT_OF_MAXIMIZED` | `user.dragEnd` | `NORMAL` | same as `DRAGGING` | 75 + | `RESIZING` | `user.resizeMove` | `RESIZING` (self-loop) | `setWindowBounds` (rAF-throttled) | 76 + | `RESIZING` | `user.resizeEnd` | `NORMAL` | pointer release; URL params updated | 77 + 78 + Any non-listed transition is illegal; the FSM should `console.warn` and ignore (or in dev, throw). 79 + 80 + --- 81 + 82 + ## Restoration inputs 83 + 84 + The page-host's "what state am I in?" decision at INITIALIZING reads from three sources: 85 + 86 + 1. **URL params on the loaded page-host URL.** Today: `x`, `y`, `width`, `height`. After this work: also `maximized` (`'1'` or absent). 87 + 2. **OS BrowserWindow bounds.** What `window-open` actually sized the window to. 88 + 3. **Display work area** (via `api.window.getDisplayInfo`) — the canonical "what does maximized mean for this display right now?". 89 + 90 + The init transition uses input #1. If `maximized=1` is present, transition to MAXIMIZED — but trust that `window-open` has already sized the window to the work area. If absent, transition to NORMAL. 91 + 92 + For the inputs to actually reach init, two upstream changes are needed: 93 + 94 + - **Session save** must persist the `maximized` flag (today it drops it via `extractRealUrl` + `extractCanvasBounds`). Add it to the `WindowDescriptor.params` payload. 95 + - **`window-open`** must propagate `maximized` to the page-host URL params it constructs in `ipc.ts:956`. Today the `pageParams` URLSearchParams is built from `{url, x, y, width, height}` only. Add `maximized` if `options.maximized === true`. 96 + - **Session restore** must pass `maximized: true` in `options` when the descriptor's saved params include it. 97 + 98 + For undo-close (`reopenLastClosedWindow`) the same propagation applies — the URL param + `pushClosedWindow` payload must carry `maximized` through. 99 + 100 + --- 101 + 102 + ## What this replaces 103 + 104 + | Replaced thing | Replaced with | 105 + |----------------|---------------| 106 + | Bare `let isMaximized = false` | `state === 'MAXIMIZED'` | 107 + | `let isDragging = false` + `pageMouseButtonDown` | `state === 'DRAGGING'` or `'DRAGGING_OUT_OF_MAXIMIZED'` | 108 + | Direct `document.body.classList.add/remove` calls scattered across `toggleMaximize`/drag/resize | A single `applyState(newState, prevState)` that owns body class + handle visibility | 109 + | `extractCanvasBounds` reading only x/y/w/h | Same function reads the maximized flag too; `WindowDescriptor.params.maximized` carries it | 110 + | `window-open`'s `pageParams` losing maximize | `pageParams` propagates `maximized` if `options.maximized === true` | 111 + 112 + The state machine is intentionally pure-renderer: it lives in `app/page/page-fsm.js`, has no IPC dependencies, takes events in and emits side-effect descriptors out (`{ setBounds: { ... } }`, `{ setBodyClass: 'maximized' }`, etc.). The runtime in `page.js` is the only thing that calls into IPC; the FSM stays trivially testable with `node:test`. 113 + 114 + Mirror of `docs/cmd-state-machine.md` — same separation: pure FSM module, runtime adapter, unit tests for the FSM, Playwright tests for the integration. 115 + 116 + --- 117 + 118 + ## Implementation order 119 + 120 + 1. **Land the failing repro** (already done): `tests/desktop/session-restore-page-host.spec.ts:163`. 121 + 2. **Extract the FSM module** (`app/page/page-fsm.js`) with the states + transition table above. No DOM, no IPC. Unit tests in `tests/unit/page-fsm.test.js` cover every legal edge + a "no illegal transitions" property. 122 + 3. **Wire page.js to the FSM.** Replace each module-scoped `let` with FSM state queries; route every body-class write through one `applyState` function. 123 + 4. **Add `maximized` propagation through the four pinch points:** `updateUrlParams` (already done), `extractCanvasBounds` (read maximized too), `WindowDescriptor` save, `window-open`'s `pageParams` construction, undo-close's saveBounds path. 124 + 5. **Make INITIALIZING transition read the URL param** and enter MAXIMIZED if set. 125 + 6. **Verify the failing spec now passes** + no regressions in the existing 5/5 reopen-closed-window suite. 126 + 127 + --- 128 + 129 + ## Open questions 130 + 131 + - Should `MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED` actually be `MAXIMIZED → NORMAL → DRAGGING`? Conceptually cleaner but means an extra transition and an intermediate `applyState` flush — risk of visual jitter. I lean toward keeping the dedicated state because `preMaximizeBounds` re-centering on cursor is its own behavior. 132 + - `extraWidth` (side-panel padding) doesn't fit cleanly as a state — it's an additive width. Keep it as a numeric attribute on the FSM (`state.extraWidth`) rather than a state. Same for `screenBounds`. 133 + - Should resize-from-maximized be supported (auto-exit maximize on grab)? Today it isn't (handles are hidden when maximized); keep that behavior for now. 134 + 135 + --- 136 + 137 + ## Related 138 + 139 + - Inspiration: `docs/cmd-state-machine.md` (cmd-panel FSM, same "pure module + runtime adapter" shape) 140 + - Inspiration: `docs/pubsub-state-machine.md` (pubsub FSM, same "single source of truth" framing) 141 + - Memory: `feedback_no_sleep_in_tests_or_code.md` — every transition in the FSM should fire on a deterministic event, no debounce/throttle except where the timeout _is_ the feature (rAF for resize, debounce for navbar hide)
+8
docs/tasks.md
··· 8 8 9 9 --- 10 10 11 + ## State machines 12 + 13 + - [ ] **Page-host FSM — extend to drag/resize states.** Phase 1 shipped 2026-04-27 (see Pruned/completed log). Phase 2: fold the remaining ad-hoc state in `app/page/page.js` (`isDragging`, `isResizing`, `webviewHoldReady`, `pageMouseButtonDown`, `dragStart*`, `preMaximizeBounds`/`preMaximizeWindowBounds`) into the FSM as DRAGGING / RESIZING / DRAGGING_OUT_OF_MAXIMIZED states per [page-host-state-machine.md](page-host-state-machine.md). Each transition gets explicit effect descriptors; runtime adapter applies them. Add Playwright coverage for drag-out-of-maximized and resize-end (currently no specs). Pure-FSM unit tests already enforce the legal transition set. 14 + 15 + --- 16 + 11 17 ## Tile architecture cleanup 12 18 13 19 - [ ] **(deferred) `tags` bg+window consolidation.** 2026-04-27 attempt deferred: `tags/home.js` is 1641 lines and pulls in CodeMirror + Vim, so making it `resident: true` just to register commands would impose a real startup cost. Re-reading the audit, the topics `tags/background.js` publishes (`editor:changed`, `editor:add`) go to the **editor** feature, not its own home — there is no actual bg↔home round-trip inside tags to eliminate. The lazy bg tile is doing exactly what lazy was designed for. Revisit only if the lazy bg lifecycle becomes a real maintenance burden. ··· 67 73 68 74 Keep short — for recent context only. Prune after a few weeks. 69 75 76 + - 2026-04-27 **Page-host FSM Phase 1 (maximize lifecycle).** New `app/page/page-fsm.js` — pure module, no DOM/IPC; states `INITIALIZING / NORMAL / MAXIMIZED`; events `INIT_COMPLETE / TOGGLE_MAXIMIZE`; effect descriptors for body class / URL param / handle visibility / pre-maximize capture/restore. Unit tests in `tests/unit/page-fsm.test.js` (15/15) cover every legal edge + illegal-transition warnings + end-to-end runs. Wired into `page.js` via `dispatchFsm` / `applyFsmEffect` — init dispatches `INIT_COMPLETE` with the `maximized` URL param so a restored maximized page-host transitions straight into MAXIMIZED. `toggleMaximize` dispatches to keep `fsmState` in sync (DOM mutations stay in toggleMaximize for now; effects are idempotent). Drag/resize/etc still own their own flags — Phase 2 will fold them in. Maximize state propagated through the four pinch points: `updateUrlParams` (writes `maximized=1`), session-save reads the URL param into `WindowDescriptor.params.maximized`, undo-close `ClosedWindowEntry.maximized`, `window-open` reads `options.maximized` and (a) snaps the BrowserWindow to the active display's work area instead of adding canvas margins, (b) propagates `maximized=1` to the new page-host URL params. Test-bypass helpers added to `saveSessionSnapshot`/`restoreSessionSnapshot` (`_forceForTest` skips `isTestProfile()` + `isVisible()` guards) and exposed via `__peek_test.forceSaveSession`/`forceRestoreSession`. New `tests/desktop/session-restore-page-host.spec.ts` (2/2) — non-maximized round-trip + maximized round-trip (the regression). Reopen-closed-window 5/5, page-load-failure 5/5, unit 610/610. 77 + - 2026-04-27 Three polish fixes from user testing (round 2): (1) **Undo-close window inflated each cycle.** `main.ts` `closed` handler subtracted only fixed canvas chrome from OS window bounds when computing saveBounds — side-panel `extraWidth` (entities/notes/etc) stayed in the saved width. On reopen that became the new WEBVIEW size, panels re-expanded the window, and each cycle compounded ("wayyy wider"). Fixed by reading the page-host URL params (`x/y/width/height` reflect screenBounds — webview-only) at close time. Special case: when the page-host is in maximized mode, screenBounds equals the work area (window == webview, no canvas chrome); `updateUrlParams` now sets `maximized=1` and main.ts falls back to fixed-margin subtraction so reopen doesn't overshoot the display. Fall back to the same subtraction for paths that never set URL params. New regression test in `tests/desktop/reopen-closed-window.spec.ts` (URL-param round-trip via `__peek_test.getHostUrlParamsByUrl`). (2) **Lists tag rows broken card.** `getItemDisplayInfo` had no branch for `itemType === 'tag'` — tag results rendered with undefined title and the question-mark fallback icon; the trash-can `onDelete` would have called `datastore.deleteItem` against a tag id (wrong primitive). Added `tag` branch (title from `item.title`, subtitle `Tag (used N×)`, `#` icon) and suppressed `onDelete` for tag rows in `lists/home.js`. New unit tests in `card-helpers-favicon.test.js`. (3) **Session-restore parity test made deterministic.** Added `window:removed` pubsub event in `main.ts` (published after `windowRegistry.delete` — the authoritative post-cleanup signal, distinct from `window:closed` which fires earlier and would gate too soon). Test subscribes via Promise pattern. 70 78 - 2026-04-27 `lists` bg+window consolidation: collapsed manifest to a single resident `home` tile, moved settings/command/shortcut wiring from `background.js` into `home.js`, deleted `background.{html,js}`. New `tests/desktop/lists-tile.spec.ts` 4/0 (manifest shape + keepLive, command registration, command-shows-window, keepLive cycle). `tags` deferred — see "Tile architecture cleanup" for rationale. 71 79 - 2026-04-27 Tile architecture follow-up to lists/websearch consolidations (3 issues surfaced when user tested): (1) **Frame default flipped to false in tile-launcher.** `createTileBrowserWindow` previously defaulted `frame: hints?.frame !== false` (titlebar by default), divergent from the legacy `api.window.open` path that always defaulted to frameless. Resident tiles inherited a titlebar, which the user explicitly disallows. Now `frame: hints?.frame === true` — opt-in only, matching project policy "no OS titlebar anywhere, ever". (2) **`keepLive` wired into tile-launcher.** New optional `TileEntry.keepLive` field; when true, the BrowserWindow's `close` event is intercepted and the window is hidden instead of destroyed, so `showSelf()` from a command handler can re-reveal it instantly. Skipped during `app.before-quit` so shutdown isn't blocked. Added `keepLive: true` to lists + websearch manifests. (3) **Lists results spacing.** `.result-group` switched from `margin-bottom`-only to `display: flex; flex-direction: column; gap: 6px` so cards have breathing room. 72 80 - 2026-04-27 Page-load failure error UI shipped: did-fail-load now renders a Peek-styled overlay with URL + reason + Retry/Close (`app/page/page.js` + `app/page/index.html`). Found that webview did-fail-load uses `validatedURL`, not `url`, and Chromium fires a second did-fail-load with empty URL for chrome-error pages — handler ignores those. Cleared on did-navigate to a non-`chrome-error://` URL. New `tests/desktop/page-load-failure.spec.ts` 4/0; redirect + navbar suites unaffected.
+4 -1
features/lists/home.js
··· 379 379 height: 768 380 380 }); 381 381 }, 382 - onDelete: async (item) => { 382 + // Tags are not deletable from a result row — the row represents a 383 + // tag *definition*, not an item; deleting it has cascade implications 384 + // and belongs in a dedicated tag manager UI. 385 + onDelete: item.type === 'tag' ? undefined : async (item) => { 383 386 if (!confirm(`Delete "${item.title || item.content || 'this item'}"?`)) return; 384 387 await api.datastore.deleteItem(item.id); 385 388 api.pubsub.publish('item:deleted', { id: item.id });
+50
tests/desktop/page-load-failure.spec.ts
··· 175 175 await closeWindow(sharedBgWindow, windowId); 176 176 } 177 177 }); 178 + 179 + test('Re-opening a previously-failed URL still shows the overlay (session-restore parity)', async () => { 180 + // User-reported regression: closing the app while a tab is on a failed 181 + // URL, then restarting, leaves the restored tab stuck on a blank page 182 + // with the loading glow permanently on. Session restore re-opens the 183 + // page-host pointing at the same URL — same code path as the user 184 + // hitting the URL again. This test simulates that by opening a new 185 + // page-host on a known-failed URL after closing the previous one. 186 + const badUrl = 'http://nonexistent-host-restoresim.invalid/'; 187 + 188 + // First open — confirm the overlay shows. 189 + const first = await openCanvasPage(sharedBgWindow, badUrl); 190 + await waitForErrorOverlay(first.pageWindow); 191 + 192 + // Arm a `window:removed` subscription BEFORE closing — this event is 193 + // published in main.ts AFTER `windowRegistry.delete`, so it's the 194 + // authoritative signal that the window is fully gone (and openWindow's 195 + // dedup-by-URL can no longer match the previous instance). 196 + await sharedBgWindow.evaluate((id: number) => { 197 + (window as any).__windowRemoved = new Promise<void>((resolve) => { 198 + const handler = (msg: { id?: number }) => { 199 + if (msg?.id === id) { 200 + try { (window as any).app.pubsub.unsubscribe('window:removed', handler); } catch {} 201 + resolve(); 202 + } 203 + }; 204 + (window as any).app.pubsub.subscribe('window:removed', handler); 205 + }); 206 + }, first.windowId); 207 + 208 + await closeWindow(sharedBgWindow, first.windowId); 209 + 210 + // Resolves when window:removed for first.windowId fires — no polling. 211 + await sharedBgWindow.evaluate(() => (window as any).__windowRemoved); 212 + 213 + // Second open — same URL, fresh page-host. The overlay must show again 214 + // and the loading glow must NOT be stuck on. 215 + const second = await openCanvasPage(sharedBgWindow, badUrl); 216 + try { 217 + await waitForErrorOverlay(second.pageWindow, 20000); 218 + // After the overlay shows, the webview must not be in 'loading' state. 219 + const stuckOnLoading = await second.pageWindow.evaluate(() => { 220 + const webview = document.getElementById('content'); 221 + return !!(webview && webview.classList.contains('loading')); 222 + }); 223 + expect(stuckOnLoading).toBe(false); 224 + } finally { 225 + await closeWindow(sharedBgWindow, second.windowId); 226 + } 227 + }); 178 228 });
+75
tests/desktop/reopen-closed-window.spec.ts
··· 245 245 } 246 246 }); 247 247 248 + test('reopened window has same webview screenBounds as original (regression: width/height growth)', async () => { 249 + // User-reported regression: closing a web-page window and using 250 + // cmd+shift+T to undo-close brings it back "wayyy wider". Root cause: 251 + // the close handler subtracted only the fixed canvas chrome from the 252 + // OS window bounds, so any side-panel extraWidth (entities/notes/…) 253 + // stayed in the saved width. On reopen that inflated value became the 254 + // WEBVIEW size, then panels expanded the window again — each cycle 255 + // compounded. The page-host URL params (x/y/width/height) hold the 256 + // canonical webview screenBounds and must round-trip exactly. 257 + 258 + const url = `http://127.0.0.1:${serverPort}/bounds-roundtrip`; 259 + const explicitW = 720; 260 + const explicitH = 540; 261 + 262 + // Open with explicit width/height (passed through to api.window.open). 263 + const openResult = await bgWindow.evaluate(async (args: { u: string; w: number; h: number }) => { 264 + return await (window as any).app.window.open(args.u, { width: args.w, height: args.h }); 265 + }, { u: url, w: explicitW, h: explicitH }); 266 + expect(openResult.success).toBe(true); 267 + const id = openResult.id as number; 268 + 269 + await findWindowWithUrl('bounds-roundtrip'); 270 + 271 + // Read page-host URL params — the authoritative webview screenBounds. 272 + const originalParams = (await app.evaluateMain!((() => { 273 + return (globalThis as any).__peek_test.getHostUrlParamsByUrl('bounds-roundtrip'); 274 + }) as any)) as { x: number; y: number; width: number; height: number } | null; 275 + expect(originalParams).not.toBeNull(); 276 + expect(originalParams!.width).toBe(explicitW); 277 + expect(originalParams!.height).toBe(explicitH); 278 + 279 + // Subscribe to window:removed BEFORE closing for a deterministic gate. 280 + await bgWindow.evaluate((closingId: number) => { 281 + (window as any).__roundtripRemoved = new Promise<void>((resolve) => { 282 + const handler = (msg: { id?: number }) => { 283 + if (msg?.id === closingId) { 284 + try { (window as any).app.pubsub.unsubscribe('window:removed', handler); } catch {} 285 + resolve(); 286 + } 287 + }; 288 + (window as any).app.pubsub.subscribe('window:removed', handler); 289 + }); 290 + }, id); 291 + 292 + await closeWindowById(id); 293 + await bgWindow.evaluate(() => (window as any).__roundtripRemoved); 294 + 295 + // Reopen via the shortcut — same code path the user hits with cmd+shift+T. 296 + const reopenResult = await reopenViaShortcut(); 297 + expect(reopenResult.success).toBe(true); 298 + expect(reopenResult.url).toContain('bounds-roundtrip'); 299 + 300 + const reopened = await findWindowWithUrl('bounds-roundtrip', 8000); 301 + expect(reopened).toBeTruthy(); 302 + 303 + const reopenedParams = (await app.evaluateMain!((() => { 304 + return (globalThis as any).__peek_test.getHostUrlParamsByUrl('bounds-roundtrip'); 305 + }) as any)) as { x: number; y: number; width: number; height: number } | null; 306 + expect(reopenedParams).not.toBeNull(); 307 + 308 + expect(reopenedParams!.width, 309 + `width drift: original=${originalParams!.width} reopened=${reopenedParams!.width}`).toBe(originalParams!.width); 310 + expect(reopenedParams!.height, 311 + `height drift: original=${originalParams!.height} reopened=${reopenedParams!.height}`).toBe(originalParams!.height); 312 + 313 + // Cleanup. 314 + const reopenedId = await bgWindow.evaluate(async (u: string) => { 315 + const r = await (window as any).app.window.list({ includeInternal: false }); 316 + if (!r.success) return null; 317 + const w = r.windows.find((win: any) => win.url?.includes(u)); 318 + return w?.id ?? null; 319 + }, 'bounds-roundtrip') as number | null; 320 + if (reopenedId != null) await closeWindowById(reopenedId); 321 + }); 322 + 248 323 test('hide-then-close keepLive tile: stack does NOT lose the most recent real close', async () => { 249 324 // A workspace tile that is `keepLive: true` is hidden (not closed) 250 325 // when you cmd+W it. If the most-recently-pushed entry was a real
+260
tests/desktop/session-restore-page-host.spec.ts
··· 1 + /** 2 + * Session restore — page-host state coherence 3 + * 4 + * User-reported regression: page hosts restored after a session are broken 5 + * — can't execute shortcuts, can't resize, "looks like a maximize call is 6 + * being made". Working hypothesis: the page-host's JS state (`isMaximized`, 7 + * body class, resize-handle visibility, screenBounds) and its OS-level 8 + * BrowserWindow bounds drift apart on restore because session restore 9 + * reconstitutes only the bounds — never the maximized flag — and the 10 + * page-host never re-derives "I was maximized" from its inputs. 11 + * 12 + * These tests pin the failure with a deterministic save+restore cycle 13 + * driven via `__peek_test.forceSaveSession` / `forceRestoreSession` 14 + * (which bypass the test-profile guard in `session.ts`). The expected 15 + * post-FSM behavior is encoded as assertions; today the maximized-restore 16 + * test will fail. 17 + * 18 + * Run with: 19 + * yarn test:grep "Session Restore Page Host" 20 + */ 21 + 22 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 23 + import { Page } from '@playwright/test'; 24 + import { createPerDescribeApp } from '../helpers/test-app'; 25 + import http from 'http'; 26 + 27 + let app: DesktopApp; 28 + let bgWindow: Page; 29 + let server: http.Server; 30 + let serverPort: number; 31 + 32 + test.describe('Session Restore Page Host @desktop', () => { 33 + test.beforeAll(async () => { 34 + ({ app, bgWindow } = await createPerDescribeApp('session-restore-page-host')); 35 + 36 + await new Promise<void>((resolve) => { 37 + server = http.createServer((req, res) => { 38 + res.writeHead(200, { 'Content-Type': 'text/html' }); 39 + res.end( 40 + `<!DOCTYPE html><html><head><title>${req.url}</title></head>` + 41 + `<body><h1>${req.url}</h1></body></html>`, 42 + ); 43 + }); 44 + server.listen(0, '127.0.0.1', () => { 45 + const addr = server.address(); 46 + serverPort = typeof addr === 'object' && addr ? addr.port : 0; 47 + resolve(); 48 + }); 49 + }); 50 + }); 51 + 52 + test.afterAll(async () => { 53 + if (server) server.close(); 54 + if (app) await app.close(); 55 + }); 56 + 57 + // Helpers -------------------------------------------------------------- 58 + 59 + async function openPageHost(url: string): Promise<{ pageWindow: Page; windowId: number }> { 60 + const result = await bgWindow.evaluate(async (u: string) => { 61 + return await (window as any).app.window.open(u, { width: 800, height: 600 }); 62 + }, url); 63 + expect(result.success).toBe(true); 64 + // Match by the URL slug so we don't pick up a leftover page-host 65 + // from an earlier test that has a different inner http URL. Slug 66 + // chosen unique per test (e.g. 'normal-roundtrip', 'max-roundtrip') 67 + // and survives URL-encoding of the page-host's `?url=` query value. 68 + const slug = new URL(url).pathname.slice(1); 69 + const pageWindow = await app.getWindow(slug, 15000); 70 + return { pageWindow, windowId: result.id }; 71 + } 72 + 73 + /** Close every page-host window and clear the saved session snapshot. */ 74 + async function resetState() { 75 + await bgWindow.evaluate(async () => { 76 + const list = await (window as any).app.window.list({ includeInternal: false }); 77 + if (!list?.success) return; 78 + for (const w of list.windows || []) { 79 + if (typeof w.url === 'string' && w.url.startsWith('peek://app/page/')) { 80 + await (window as any).app.window.close(w.id); 81 + } 82 + } 83 + }); 84 + // Wait for close to complete (window:removed for the last close). 85 + // Lightweight: poll the list until empty of page-host windows. 86 + await bgWindow.waitForFunction(async () => { 87 + const list = await (window as any).app.window.list({ includeInternal: false }); 88 + if (!list?.success) return false; 89 + return !(list.windows || []).some((w: any) => 90 + typeof w.url === 'string' && w.url.startsWith('peek://app/page/')); 91 + }); 92 + } 93 + 94 + async function getHostState(pageWindow: Page) { 95 + return await pageWindow.evaluate(() => { 96 + return { 97 + bodyMaximized: document.body.classList.contains('maximized'), 98 + urlParams: Object.fromEntries(new URL(window.location.href).searchParams.entries()), 99 + // Resize handles set display:none in updatePositionsMaximized. 100 + // After the FSM lands, we want one source of truth — body class. 101 + // Today we read both to surface drift. 102 + seHandleHidden: (() => { 103 + const el = document.getElementById('resize-se'); 104 + return el ? el.style.display === 'none' : null; 105 + })(), 106 + }; 107 + }); 108 + } 109 + 110 + /** 111 + * Trigger the page-host's `toggleMaximize` by dispatching a dblclick on 112 + * the navbar element — same path the user takes. Waits for 113 + * `window.__pageModuleReady` (set at the end of page.js module eval) so 114 + * the dblclick listener at the bottom of page.js is guaranteed installed 115 + * before we dispatch. 116 + */ 117 + async function maximizePageHost(pageWindow: Page) { 118 + await pageWindow.waitForFunction(() => (window as any).__pageModuleReady === true); 119 + await pageWindow.evaluate(() => { 120 + const nav = document.getElementById('navbar')!; 121 + nav.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })); 122 + }); 123 + } 124 + 125 + async function waitForBodyMaximized(pageWindow: Page, expected: boolean, timeoutMs = 5000) { 126 + await pageWindow.waitForFunction( 127 + (want: boolean) => document.body.classList.contains('maximized') === want, 128 + expected, 129 + { timeout: timeoutMs }, 130 + ); 131 + } 132 + 133 + test.beforeEach(async () => { 134 + await resetState(); 135 + }); 136 + 137 + // Tests ---------------------------------------------------------------- 138 + 139 + test('non-maximized page host: bounds round-trip through save+restore', async () => { 140 + const url = `http://127.0.0.1:${serverPort}/normal-roundtrip`; 141 + const { pageWindow } = await openPageHost(url); 142 + 143 + // Wait for page-host to fully initialize (URL params populated). 144 + await pageWindow.waitForFunction(() => { 145 + const p = new URL(window.location.href).searchParams; 146 + return p.get('width') && p.get('height'); 147 + }); 148 + 149 + const beforeState = await getHostState(pageWindow); 150 + expect(beforeState.bodyMaximized).toBe(false); 151 + 152 + // Save the snapshot before closing. 153 + await app.evaluateMain!((() => { 154 + return (globalThis as any).__peek_test.forceSaveSession(); 155 + }) as any); 156 + 157 + // Close the window and wait for window:removed for determinism. 158 + const initialWindowId = await pageWindow.evaluate(async () => { 159 + const r = await (window as any).app.window.getInfo(); 160 + return r?.id ?? null; 161 + }); 162 + await bgWindow.evaluate((id: number) => { 163 + (window as any).__sessionRemoved = new Promise<void>((resolve) => { 164 + const handler = (msg: { id?: number }) => { 165 + if (msg?.id === id) { 166 + try { (window as any).app.pubsub.unsubscribe('window:removed', handler); } catch {} 167 + resolve(); 168 + } 169 + }; 170 + (window as any).app.pubsub.subscribe('window:removed', handler); 171 + }); 172 + (window as any).app.window.close(id); 173 + }, initialWindowId); 174 + await bgWindow.evaluate(() => (window as any).__sessionRemoved); 175 + 176 + // Now restore. 177 + const restoreResult = await app.evaluateMain!((() => { 178 + return (globalThis as any).__peek_test.forceRestoreSession(); 179 + }) as any) as { restored: number; failed: number; total: number }; 180 + expect(restoreResult.restored).toBeGreaterThan(0); 181 + 182 + // Wait for the restored window to appear. 183 + const restoredPageWindow = await app.getWindow('normal-roundtrip', 15000); 184 + await restoredPageWindow.waitForFunction(() => { 185 + const p = new URL(window.location.href).searchParams; 186 + return p.get('width') && p.get('height'); 187 + }); 188 + 189 + const afterState = await getHostState(restoredPageWindow); 190 + expect(afterState.bodyMaximized).toBe(false); 191 + expect(afterState.urlParams.width).toBe(beforeState.urlParams.width); 192 + expect(afterState.urlParams.height).toBe(beforeState.urlParams.height); 193 + }); 194 + 195 + test('maximized page host: maximize state survives save+restore (regression: restored windows lose maximized state)', async () => { 196 + const url = `http://127.0.0.1:${serverPort}/maximized-roundtrip`; 197 + const { pageWindow, windowId } = await openPageHost(url); 198 + 199 + // Wait for init. 200 + await pageWindow.waitForFunction(() => { 201 + const p = new URL(window.location.href).searchParams; 202 + return p.get('width') && p.get('height'); 203 + }); 204 + 205 + // Maximize via navbar dblclick (same path the user takes). 206 + await maximizePageHost(pageWindow); 207 + await waitForBodyMaximized(pageWindow, true); 208 + 209 + const beforeState = await getHostState(pageWindow); 210 + expect(beforeState.bodyMaximized).toBe(true); 211 + expect(beforeState.urlParams.maximized).toBe('1'); 212 + expect(beforeState.seHandleHidden).toBe(true); 213 + 214 + // Save snapshot. 215 + await app.evaluateMain!((() => { 216 + return (globalThis as any).__peek_test.forceSaveSession(); 217 + }) as any); 218 + 219 + // Close and wait deterministically. 220 + await bgWindow.evaluate((id: number) => { 221 + (window as any).__maxRemoved = new Promise<void>((resolve) => { 222 + const handler = (msg: { id?: number }) => { 223 + if (msg?.id === id) { 224 + try { (window as any).app.pubsub.unsubscribe('window:removed', handler); } catch {} 225 + resolve(); 226 + } 227 + }; 228 + (window as any).app.pubsub.subscribe('window:removed', handler); 229 + }); 230 + (window as any).app.window.close(id); 231 + }, windowId); 232 + await bgWindow.evaluate(() => (window as any).__maxRemoved); 233 + 234 + // Restore. 235 + const restoreResult = await app.evaluateMain!((() => { 236 + return (globalThis as any).__peek_test.forceRestoreSession(); 237 + }) as any) as { restored: number; failed: number; total: number }; 238 + expect(restoreResult.restored).toBeGreaterThan(0); 239 + 240 + const restoredPageWindow = await app.getWindow('maximized-roundtrip', 15000); 241 + await restoredPageWindow.waitForFunction(() => { 242 + const p = new URL(window.location.href).searchParams; 243 + return p.get('width') && p.get('height'); 244 + }); 245 + 246 + const afterState = await getHostState(restoredPageWindow); 247 + 248 + // ── Expectations the FSM must meet ───────────────────────────── 249 + // 1) The body class must reflect maximized state. 250 + expect(afterState.bodyMaximized, 251 + `restored page host lost body.maximized class — JS state thinks it's not maximized even though the saved URL had maximized=1`).toBe(true); 252 + // 2) URL params should still carry the maximized flag. 253 + expect(afterState.urlParams.maximized, 254 + `restored page host lost maximized=1 URL param — page-host URL is regenerated by window-open without propagating the maximize state`).toBe('1'); 255 + // 3) Resize handles must be hidden when maximized — drift between 256 + // body class and handle visibility surfaces state-incoherence. 257 + expect(afterState.seHandleHidden, 258 + `restored page host shows resize handles even though body class says maximized — state drift`).toBe(true); 259 + }); 260 + });
+21
tests/unit/card-helpers-favicon.test.js
··· 20 20 const TYPE_ICONS = { 21 21 url: GLOBE_FAVICON, 22 22 text: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F4DD}</text></svg>', 23 + tag: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">#</text></svg>', 23 24 tagset: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F3F7}\uFE0F</text></svg>', 24 25 image: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F5BC}\uFE0F</text></svg>', 25 26 entity: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F48E}</text></svg>', ··· 92 93 title = item.title || (item.content || '').substring(0, 100) + ((item.content || '').length > 100 ? '...' : ''); 93 94 subtitle = noteUrl || ''; 94 95 faviconUrl = TYPE_ICONS.text; 96 + } else if (itemType === 'tag') { 97 + title = item.title || item.name || 'Tag'; 98 + const freq = item.frequency || 0; 99 + subtitle = `Tag${freq ? ` (used ${freq}×)` : ''}`; 100 + faviconUrl = TYPE_ICONS.tag; 95 101 } else if (itemType === 'tagset') { 96 102 title = 'Tag Set'; 97 103 subtitle = tags.length > 0 ? tags.map(t => t.name).join(', ') : 'Empty tagset'; ··· 334 340 const item = { type: 'url' }; 335 341 const info = getItemDisplayInfo(item); 336 342 assert.equal(info.title, '(untitled)'); 343 + }); 344 + 345 + it('tag items show their name as title and frequency in subtitle', () => { 346 + const item = { type: 'tag', title: 'mytag', frequency: 5 }; 347 + const info = getItemDisplayInfo(item); 348 + assert.equal(info.title, 'mytag'); 349 + assert.equal(info.subtitle, 'Tag (used 5×)'); 350 + assert.equal(info.faviconUrl, TYPE_ICONS.tag); 351 + }); 352 + 353 + it('tag items omit frequency when zero', () => { 354 + const item = { type: 'tag', title: 'newtag' }; 355 + const info = getItemDisplayInfo(item); 356 + assert.equal(info.title, 'newtag'); 357 + assert.equal(info.subtitle, 'Tag'); 337 358 }); 338 359 339 360 it('tagset subtitle shows tag names', () => {
+185
tests/unit/page-fsm.test.js
··· 1 + /** 2 + * Unit tests for app/page/page-fsm.js — pure state machine for the 3 + * page-host renderer's NORMAL ↔ MAXIMIZED lifecycle. 4 + * 5 + * No DOM; no IPC; no api.* — just transition() input → output. 6 + * Run via the standard Electron-as-node unit-test runner: 7 + * yarn test:unit tests/unit/page-fsm.test.js 8 + */ 9 + 10 + import { describe, it } from 'node:test'; 11 + import assert from 'node:assert/strict'; 12 + import { 13 + STATES, EVENTS, EFFECTS, INITIAL_STATE, transition, run, 14 + } from '../../app/page/page-fsm.js'; 15 + 16 + const effectTypes = (effects) => effects.map(e => e.type); 17 + 18 + describe('page-fsm: initial state', () => { 19 + it('starts in INITIALIZING', () => { 20 + assert.equal(INITIAL_STATE, STATES.INITIALIZING); 21 + }); 22 + }); 23 + 24 + describe('page-fsm: INITIALIZING → NORMAL (no maximize hint)', () => { 25 + it('init.complete with maximized=false transitions to NORMAL with no effects', () => { 26 + const { state, effects } = transition(STATES.INITIALIZING, { 27 + type: EVENTS.INIT_COMPLETE, 28 + maximized: false, 29 + }); 30 + assert.equal(state, STATES.NORMAL); 31 + assert.deepEqual(effects, []); 32 + }); 33 + 34 + it('init.complete with maximized omitted defaults to NORMAL', () => { 35 + const { state, effects } = transition(STATES.INITIALIZING, { 36 + type: EVENTS.INIT_COMPLETE, 37 + }); 38 + assert.equal(state, STATES.NORMAL); 39 + assert.deepEqual(effects, []); 40 + }); 41 + }); 42 + 43 + describe('page-fsm: INITIALIZING → MAXIMIZED (restored from session)', () => { 44 + it('init.complete with maximized=true transitions to MAXIMIZED with body/handle effects', () => { 45 + const { state, effects } = transition(STATES.INITIALIZING, { 46 + type: EVENTS.INIT_COMPLETE, 47 + maximized: true, 48 + }); 49 + assert.equal(state, STATES.MAXIMIZED); 50 + // The effects must keep DOM observables in sync with the state: 51 + // body class on, handles hidden. No setBounds — the browser window 52 + // already came in sized to the work area. 53 + assert.deepEqual(effectTypes(effects), [ 54 + EFFECTS.SET_BODY_MAXIMIZED, 55 + EFFECTS.SET_HANDLES_VISIBLE, 56 + ]); 57 + assert.equal(effects[0].value, true); 58 + assert.equal(effects[1].value, false); 59 + }); 60 + 61 + it('does NOT emit ENTER_MAXIMIZED_LAYOUT on init — screenBounds are already maximized', () => { 62 + const { effects } = transition(STATES.INITIALIZING, { 63 + type: EVENTS.INIT_COMPLETE, 64 + maximized: true, 65 + }); 66 + assert.ok(!effectTypes(effects).includes(EFFECTS.ENTER_MAXIMIZED_LAYOUT)); 67 + }); 68 + }); 69 + 70 + describe('page-fsm: NORMAL → MAXIMIZED', () => { 71 + it('toggle_maximize captures pre-state, applies layout, syncs body/handles/URL', () => { 72 + const { state, effects } = transition(STATES.NORMAL, { 73 + type: EVENTS.TOGGLE_MAXIMIZE, 74 + }); 75 + assert.equal(state, STATES.MAXIMIZED); 76 + assert.deepEqual(effectTypes(effects), [ 77 + EFFECTS.CAPTURE_PRE_MAXIMIZE, 78 + EFFECTS.ENTER_MAXIMIZED_LAYOUT, 79 + EFFECTS.SET_BODY_MAXIMIZED, 80 + EFFECTS.SET_HANDLES_VISIBLE, 81 + EFFECTS.SET_URL_MAXIMIZED, 82 + ]); 83 + }); 84 + 85 + it('CAPTURE_PRE_MAXIMIZE comes before ENTER_MAXIMIZED_LAYOUT (must capture before clobber)', () => { 86 + const { effects } = transition(STATES.NORMAL, { 87 + type: EVENTS.TOGGLE_MAXIMIZE, 88 + }); 89 + const capIdx = effectTypes(effects).indexOf(EFFECTS.CAPTURE_PRE_MAXIMIZE); 90 + const enterIdx = effectTypes(effects).indexOf(EFFECTS.ENTER_MAXIMIZED_LAYOUT); 91 + assert.ok(capIdx >= 0 && enterIdx >= 0); 92 + assert.ok(capIdx < enterIdx, 'must capture pre-state before entering maximized layout'); 93 + }); 94 + }); 95 + 96 + describe('page-fsm: MAXIMIZED → NORMAL', () => { 97 + it('toggle_maximize restores pre-state and clears body/handles/URL', () => { 98 + const { state, effects } = transition(STATES.MAXIMIZED, { 99 + type: EVENTS.TOGGLE_MAXIMIZE, 100 + }); 101 + assert.equal(state, STATES.NORMAL); 102 + assert.deepEqual(effectTypes(effects), [ 103 + EFFECTS.RESTORE_PRE_MAXIMIZE, 104 + EFFECTS.SET_BODY_MAXIMIZED, 105 + EFFECTS.SET_HANDLES_VISIBLE, 106 + EFFECTS.SET_URL_MAXIMIZED, 107 + ]); 108 + // body off, handles on, url removed 109 + const setBody = effects.find(e => e.type === EFFECTS.SET_BODY_MAXIMIZED); 110 + const setHandles = effects.find(e => e.type === EFFECTS.SET_HANDLES_VISIBLE); 111 + const setUrl = effects.find(e => e.type === EFFECTS.SET_URL_MAXIMIZED); 112 + assert.equal(setBody.value, false); 113 + assert.equal(setHandles.value, true); 114 + assert.equal(setUrl.value, false); 115 + }); 116 + }); 117 + 118 + describe('page-fsm: illegal transitions', () => { 119 + it('TOGGLE_MAXIMIZE in INITIALIZING is rejected with a warning, state unchanged', () => { 120 + const { state, effects, warning } = transition(STATES.INITIALIZING, { 121 + type: EVENTS.TOGGLE_MAXIMIZE, 122 + }); 123 + assert.equal(state, STATES.INITIALIZING); 124 + assert.deepEqual(effects, []); 125 + assert.match(warning, /Illegal event/); 126 + }); 127 + 128 + it('duplicate INIT_COMPLETE in NORMAL is a no-op with a warning', () => { 129 + const { state, effects, warning } = transition(STATES.NORMAL, { 130 + type: EVENTS.INIT_COMPLETE, 131 + }); 132 + assert.equal(state, STATES.NORMAL); 133 + assert.deepEqual(effects, []); 134 + assert.match(warning, /Duplicate INIT_COMPLETE/); 135 + }); 136 + 137 + it('duplicate INIT_COMPLETE in MAXIMIZED is a no-op with a warning', () => { 138 + const { state, effects, warning } = transition(STATES.MAXIMIZED, { 139 + type: EVENTS.INIT_COMPLETE, 140 + }); 141 + assert.equal(state, STATES.MAXIMIZED); 142 + assert.deepEqual(effects, []); 143 + assert.match(warning, /Duplicate INIT_COMPLETE/); 144 + }); 145 + 146 + it('malformed event (missing type) is rejected', () => { 147 + const { state, effects, warning } = transition(STATES.NORMAL, null); 148 + assert.equal(state, STATES.NORMAL); 149 + assert.deepEqual(effects, []); 150 + assert.match(warning, /malformed event/); 151 + }); 152 + }); 153 + 154 + describe('page-fsm: end-to-end run() — round-trip via maximize toggle', () => { 155 + it('init→maximize→unmaximize lands back in NORMAL with no leftover effects', () => { 156 + const { state } = run([ 157 + { type: EVENTS.INIT_COMPLETE, maximized: false }, 158 + { type: EVENTS.TOGGLE_MAXIMIZE }, 159 + { type: EVENTS.TOGGLE_MAXIMIZE }, 160 + ]); 161 + assert.equal(state, STATES.NORMAL); 162 + }); 163 + 164 + it('restored maximized window stays MAXIMIZED until user toggles', () => { 165 + const { state, effects } = run([ 166 + { type: EVENTS.INIT_COMPLETE, maximized: true }, 167 + ]); 168 + assert.equal(state, STATES.MAXIMIZED); 169 + // Body class + handle visibility must be set so DOM matches FSM state 170 + // — this is exactly the assertion the failing Playwright spec encodes. 171 + const types = effectTypes(effects); 172 + assert.ok(types.includes(EFFECTS.SET_BODY_MAXIMIZED)); 173 + assert.ok(types.includes(EFFECTS.SET_HANDLES_VISIBLE)); 174 + }); 175 + 176 + it('restored maximized window then user toggle: lands in NORMAL with restore effects', () => { 177 + const { state, effects } = run([ 178 + { type: EVENTS.INIT_COMPLETE, maximized: true }, 179 + { type: EVENTS.TOGGLE_MAXIMIZE }, 180 + ]); 181 + assert.equal(state, STATES.NORMAL); 182 + // The exit-MAXIMIZED transition must restore pre-state 183 + assert.ok(effectTypes(effects).includes(EFFECTS.RESTORE_PRE_MAXIMIZE)); 184 + }); 185 + });