feat(page): page-host FSM Phase 2 — drag/resize/drag-out-of-maximized states
Extends the FSM from Phase 1 (NORMAL ↔ MAXIMIZED) with the remaining
states from docs/page-host-state-machine.md:
* DRAGGING — entered on DRAG_START from NORMAL (navbar mousedown,
hold-to-drag, webview hold-drag). Exits on DRAG_END.
* RESIZING — entered on RESIZE_START (resize-handle pointerdown).
Exits on RESIZE_END. No SET_HANDLES_VISIBLE side effect — the
active handle stays visible through pointer capture.
* DRAGGING_OUT_OF_MAXIMIZED — special transition for the case where
the user mousedown's the navbar while maximized. The FSM emits
EXIT_MAXIMIZED_FOR_DRAG + SET_BODY_MAXIMIZED(false) +
SET_HANDLES_VISIBLE(true) + SET_URL_MAXIMIZED(false) +
SHOW_DRAG_OVERLAY in that order. Exit always lands in NORMAL —
drag-out never re-enters MAXIMIZED, matching today's behavior.
Self-loops on DRAG_MOVE / RESIZE_MOVE are no-op transitions (the
runtime adapter owns the screenBounds + delta math; FSM just
acknowledges). Duplicate DRAG_START / RESIZE_START / INIT_COMPLETE
inside the same state are tolerated no-ops — a console.warn flood from
a real-world input burst would be worse than silent.
Wiring in app/page/page.js:
* dispatchFsm at the start of startDrag() — handles both NORMAL→
DRAGGING and MAXIMIZED→DRAGGING_OUT_OF_MAXIMIZED via the same
event dispatch.
* dispatchFsm at every drag-end site (toggleMaximize cancel, webview
mouseup, document mousemove orphan-cleanup, document mouseup).
* dispatchFsm in resize handle pointerdown + cancelResize.
* applyFsmEffect handles SHOW_DRAG_OVERLAY / HIDE_DRAG_OVERLAY by
toggling dragOverlay's `active` class. EXIT_MAXIMIZED_FOR_DRAG
is currently a no-op in the adapter (page.js's startDrag still
owns the actual bounds restoration); promoting it to the runtime
adapter is part of Phase 3.
Tests:
* tests/unit/page-fsm.test.js: 15 → 33 tests (every legal edge for
the new states + illegal-transition warnings + end-to-end runs).
* tests/desktop/page-host-fsm.spec.ts (new, 5/5): drives real DOM
events through the page-host and asserts window.__pageFsmState
matches expectations after each round-trip. Covers init/drag/
resize/maximize/drag-out-of-maximize.
window.__pageFsmState is exposed for Playwright introspection only —
production code should never read it. The FSM's transition() API is
the only sanctioned interface.
Page.js still uses module-scoped `isDragging` / `isResizing` /
`isMaximized` flags as the source of truth — the FSM is shadow state,
kept in sync via dispatch + idempotent applyFsmEffect. Phase 3 will
flip the source of truth and delete the flags. Tracked in
docs/tasks.md.
Test results:
* unit: 628/628 (was 610 + 18 new page-fsm tests)
* page-host-fsm spec: 5/5 (new)
* session-restore-page-host: 2/2 (Phase 1 regression repro stays green)
* reopen-closed-window: 5/5
* page-load-failure: 5/5