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)