···11+# Window Placement Refactor — Sprint Plan
22+33+**Goal:** Eliminate ad-hoc display/positioning logic scattered across `ipc.ts`,
44+`tile-ipc.ts`, `display-watcher.ts`, and `features/slides/background.js`.
55+Replace with one pure module driven by a `Placement` intent recorded on the
66+window registry. Make display-change behavior deterministically unit-testable
77+without real multi-monitor hardware.
88+99+**Non-goals:** No FSM. No IZUI integration. No session-restore changes beyond
1010+the minimum needed to populate `placement`. No slides-feature redesign — only
1111+moving the math from renderer to main.
1212+1313+**Sprint discipline:** No e2e tests, no manual repro, no shipping until all
1414+phases are complete. Each phase is committable on its own but stays in the
1515+local stack until the sprint closes. Phase exit criteria are **build clean +
1616+unit tests pass** — that's it.
1717+1818+---
1919+2020+## The model
2121+2222+### `Placement` discriminated union
2323+2424+```ts
2525+// backend/electron/window-placement.ts
2626+2727+export type Placement =
2828+ | { mode: 'centered' }
2929+ // Always centered on the cursor's display, every time the window
3030+ // is shown. cmd panel, modal palettes, "center: true" callers.
3131+ | { mode: 'cursor-display-fallback' }
3232+ // Centered on cursor display ONLY if no explicit position was
3333+ // ever provided. Once positioned, stays put across reuse —
3434+ // unless stranded (see below). Page-host default.
3535+ | { mode: 'edge'; edge: 'top' | 'bottom' | 'left' | 'right' }
3636+ // Anchored to one edge of the cursor's display, every show.
3737+ // Slides.
3838+ | { mode: 'parent-centered'; parentId: number }
3939+ // Centered on the parent window's bounds. Quick-views, child
4040+ // dialogs.
4141+ | { mode: 'manual' };
4242+ // User-positioned (drag, manual setBounds). Never auto-moved
4343+ // EXCEPT when stranded.
4444+4545+export interface PlacementInput {
4646+ placement: Placement;
4747+ currentBounds: Electron.Rectangle; // window's bounds right now
4848+ windowSize: { width: number; height: number }; // desired/intrinsic size
4949+ displays: Electron.Display[];
5050+ cursorPoint: Electron.Point;
5151+ parentBounds?: Electron.Rectangle; // required for parent-centered
5252+}
5353+5454+export type PlacementResult =
5555+ | { kind: 'no-change' }
5656+ | { kind: 'reposition'; bounds: Electron.Rectangle };
5757+```
5858+5959+### The single function
6060+6161+```ts
6262+export function computePlacement(input: PlacementInput): PlacementResult;
6363+```
6464+6565+**Contract:**
6666+6767+- Pure. No `screen.*` calls inside. No `BrowserWindow.*`. Caller passes in
6868+ display state.
6969+- Returns `no-change` if the window is already correctly placed for the given
7070+ placement intent. Callers should skip `setBounds()` in that case.
7171+- For every mode, "stranded" rescue is built in: if `currentBounds` has <50%
7272+ area on any display, the result is always `reposition` to the cursor
7373+ display, regardless of mode. (Replaces today's `isWindowAccessibleNow` +
7474+ `repositionOnCursorDisplay`.)
7575+- Output bounds always clamp size to the chosen display's `workArea`.
7676+7777+### Where intent gets set
7878+7979+`Placement` is assigned **once at window-open time**, stored on
8080+`windowRegistry.params.placement`, and never re-derived from flag soup
8181+(`center: true`, explicit `x/y`, `useCanvas`, etc.). All consumers — reuse
8282+paths, display-watcher, fresh-open fallback — read from the registry.
8383+8484+Mapping from current flags to `Placement`:
8585+8686+| Current flags | Placement |
8787+|------------------------------------------------------------|------------------------------------------------------|
8888+| `options.center === true` | `{ mode: 'centered' }` |
8989+| `options.x` / `options.y` provided + valid | `{ mode: 'manual' }` |
9090+| Has real parent + no explicit position | `{ mode: 'parent-centered', parentId }` |
9191+| Slides feature (`screenEdge` in opts) | `{ mode: 'edge', edge: <screenEdge> }` |
9292+| Everything else (page-host, generic web pages, new-tab) | `{ mode: 'cursor-display-fallback' }` |
9393+9494+Slides currently passes explicit `x`/`y` from the renderer's stale
9595+`window.screen` — Phase 4 deletes that and passes `screenEdge` instead.
9696+9797+---
9898+9999+## Phase plan
100100+101101+Each phase has: scope, files, exit criteria, what's explicitly **out**.
102102+103103+### Phase 1 — Pure module + unit tests + fake-displays helper
104104+105105+**Scope:**
106106+- New file `backend/electron/window-placement.ts` exporting `Placement`,
107107+ `PlacementInput`, `PlacementResult`, and `computePlacement`.
108108+- New file `backend/electron/window-placement.test.ts` (or
109109+ `tests/unit/window-placement.test.js`, matching project unit-test
110110+ conventions — agent decides based on existing layout) with a
111111+ `fakeDisplays(...)` helper and exhaustive coverage of every `Placement`
112112+ mode + the stranded-rescue path.
113113+- No production code touched. No imports added to `ipc.ts` etc.
114114+115115+**Files touched:**
116116+- `backend/electron/window-placement.ts` (new)
117117+- Test file (new)
118118+119119+**Test cases (minimum):**
120120+1. `centered` on cursor display A, displays unchanged, cursor on A → `no-change`.
121121+2. `centered` on cursor display A, cursor moves to B → `reposition` to B's center.
122122+3. `centered`, A removed entirely → `reposition` to (now-only) display.
123123+4. `cursor-display-fallback`, window currently fits on its display, cursor moves
124124+ to other display → `no-change` (unlike `centered`, doesn't follow cursor).
125125+5. `cursor-display-fallback`, window stranded (display unplugged) → `reposition`
126126+ to cursor display center.
127127+6. `edge: 'top'`, cursor on A → bounds anchored to A's top edge, X-centered.
128128+7. `edge: 'top'`, cursor moves to B → re-anchor to B's top edge.
129129+8. `parent-centered`, parent on A → centered on parent's bounds.
130130+9. `parent-centered`, parent has been destroyed (parentBounds undefined) → fall
131131+ back to `cursor-display-fallback` semantics (or whatever we decide).
132132+10. `manual`, window fits on some display → `no-change`.
133133+11. `manual`, window stranded → `reposition` (rescue) to cursor display.
134134+12. Window size larger than target display → output clamped to display workArea.
135135+136136+**Exit criteria:**
137137+- `yarn build` clean.
138138+- Unit tests pass via `yarn test:unit` (or whatever runs the new test file).
139139+- Zero references to `window-placement.ts` from production code yet — this is
140140+ a pure addition.
141141+142142+**Out of scope:** wiring into existing call sites, deleting old helpers,
143143+session-restore.
144144+145145+---
146146+147147+### Phase 2 — `placement` field on windowRegistry, set at every open call site
148148+149149+**Scope:**
150150+- Add `placement: Placement` field to `windowRegistry.params` shape
151151+ (`backend/electron/main.ts`).
152152+- At every `registerWindow()` call site, derive and pass `placement` per the
153153+ mapping table above.
154154+- Slides feature: pass `screenEdge` through the open options object so the
155155+ open path can record `{ mode: 'edge', edge }` (renderer math stays in place
156156+ for now — Phase 4 strips it).
157157+- No behavior change. The new field is data-only. Reuse paths still use the
158158+ current `isWindowAccessibleNow` + `center: true` branch logic.
159159+160160+**Files touched:**
161161+- `backend/electron/main.ts` (registry shape)
162162+- `backend/electron/ipc.ts` (window-open handler — assign placement)
163163+- `backend/electron/tile-ipc.ts` (v2 tile open path — assign placement)
164164+- `features/slides/background.js` (pass `screenEdge` in options)
165165+166166+**Exit criteria:**
167167+- `yarn build` clean.
168168+- Unit tests still pass.
169169+- Logging assertion: every newly registered window has a non-undefined
170170+ `params.placement`. Agent should add a one-line `console.log` audit (then
171171+ remove before done) or write a tiny unit test that exercises the assignment
172172+ via the existing `registerWindow` contract.
173173+174174+**Out of scope:** consuming the new field. Reuse paths and display-watcher
175175+still use today's logic. Slides renderer keeps doing its `window.screen` math.
176176+177177+---
178178+179179+### Phase 3 — Replace ad-hoc positioning with `computePlacement` calls
180180+181181+**Scope (three call-site groups, one phase):**
182182+183183+- **Reuse paths in `ipc.ts`:**
184184+ - URL-reuse path (~ipc.ts:580): replace inline
185185+ `isWindowAccessibleNow + repositionOnCursorDisplay` with a single
186186+ `computePlacement` call using the stored `placement`. Apply result.
187187+ - keepLive-reuse path (~ipc.ts:580–625): replace the `center: true` /
188188+ stranded branches with a single `computePlacement` call.
189189+ - Delete `isWindowAccessibleNow` and `repositionOnCursorDisplay` helpers
190190+ (now redundant).
191191+192192+- **Fresh-open positioning in `ipc.ts:737–797`:** replace the cursor-display
193193+ centering fallback (and the canvas-page variant at 791–797) with a
194194+ `computePlacement` call seeded from the just-derived `placement`.
195195+196196+- **Display-watcher in `display-watcher.ts`:** replace the
197197+ `center: true`-only second pass with a generic pass that calls
198198+ `computePlacement` for every visible window using its registry-stored
199199+ `placement`. The first-pass orphan rescue stays — it's the catch-all for
200200+ windows whose `placement` produced bounds outside `workArea` (defense in
201201+ depth) and for windows registered before placement was added.
202202+203203+**Files touched:**
204204+- `backend/electron/ipc.ts`
205205+- `backend/electron/display-watcher.ts`
206206+207207+**Exit criteria:**
208208+- `yarn build` clean.
209209+- Unit tests pass.
210210+- Old helpers (`isWindowAccessibleNow`, `repositionOnCursorDisplay`) deleted.
211211+- No remaining `screen.getDisplayNearestPoint(screen.getCursorScreenPoint())`
212212+ call in `ipc.ts` — every position decision flows through `computePlacement`.
213213+214214+**Out of scope:** `tile-ipc.ts` v2 path. Slides renderer cleanup. Session
215215+restore.
216216+217217+---
218218+219219+### Phase 4 — Slides renderer cleanup
220220+221221+**Scope:**
222222+- Delete the `window.screen.width/height`-based x/y math in
223223+ `features/slides/background.js` (the loop around lines 66–88 in the file as
224224+ it stood at audit time).
225225+- Slides now pass only `{ screenEdge, width, height }` to `api.window.open`.
226226+- The window-open path (Phase 2 + 3) reads `screenEdge`, sets
227227+ `placement: { mode: 'edge', edge }`, and main-process `computePlacement`
228228+ produces coords against the actual cursor display every time the slide is
229229+ shown.
230230+- This fixes the "slide on wrong display, both still exist" case (the
231231+ partially-fixed remainder from the recent display-switch sprint) without
232232+ any IZUI changes.
233233+234234+**Files touched:**
235235+- `features/slides/background.js`
236236+237237+**Exit criteria:**
238238+- `yarn build` clean.
239239+- Unit tests pass.
240240+- Slides feature has zero references to `window.screen.width` or
241241+ `window.screen.height`.
242242+243243+**Out of scope:** any other renderer that reads `window.screen` (track as
244244+followup if found).
245245+246246+---
247247+248248+### Phase 5 — `tile-ipc.ts` v2 positioning consolidation
249249+250250+**Scope:**
251251+- Replace the position-computation calls in `tile-ipc.ts` (audit identified
252252+ lines ~2884–2891, ~2919–2926, ~3344) with `computePlacement`.
253253+- `createTileBrowserWindow` and friends should populate `placement` on the
254254+ registered tile params so reuse + display-watcher see consistent data for
255255+ v2 tiles.
256256+257257+**Files touched:**
258258+- `backend/electron/tile-ipc.ts`
259259+260260+**Exit criteria:**
261261+- `yarn build` clean.
262262+- Unit tests pass.
263263+- No remaining direct `screen.*` position calls in `tile-ipc.ts`.
264264+265265+**Out of scope:** anything that's not v2 tile positioning.
266266+267267+---
268268+269269+### Phase 6 — Final sweep + sprint validation
270270+271271+**Scope:**
272272+- Full unit test run (`yarn test:unit`).
273273+- Full Playwright run (`yarn test:electron:bg`) — first time we run e2e for
274274+ the sprint.
275275+- Manual multi-display repro: cmd panel re-center, page-host stranded
276276+ rescue, slides on cursor display, slides re-anchor on display change.
277277+- Fix any regressions surfaced; do NOT add new features.
278278+- Mark `docs/tasks.md` entries 32 + 33 (page-host display switch / slides
279279+ anchor) as fully resolved.
280280+- Add a one-paragraph "Window placement" section to `docs/architecture.md`
281281+ pointing at `window-placement.ts` as the canonical source of truth.
282282+283283+**Files touched:**
284284+- `docs/tasks.md`
285285+- `docs/architecture.md`
286286+- whatever needs fixing from validation
287287+288288+**Exit criteria:**
289289+- All tests green.
290290+- Multi-display manual repros pass.
291291+- Sprint ready to ship as a single rebase onto main.
292292+293293+---
294294+295295+## Test strategy
296296+297297+The point of this sprint is to make display-switch behavior testable in unit
298298+tests, with no real screens involved. Phase 1's `fakeDisplays(...)` helper
299299+is the lever:
300300+301301+```ts
302302+function fakeDisplays(specs: Array<{ id: number; x: number; y: number; w: number; h: number; primary?: boolean }>): Display[]
303303+```
304304+305305+Pass synthetic display arrays + cursor points into `computePlacement` and
306306+assert the result. Every regression we've shipped in this area would have
307307+been a single failing unit test:
308308+309309+- "External monitor unplugged, cmd panel still on the laptop screen but at old
310310+ external coordinates" → `computePlacement({ placement: { mode: 'centered' }, currentBounds: <on missing display>, displays: [laptopOnly], cursorPoint: laptop.center })`
311311+ → expect `reposition` to laptop center.
312312+- "Slide opened, user moves to second display, slide stays on first" →
313313+ `computePlacement({ placement: { mode: 'edge', edge: 'top' }, currentBounds: <top of A>, displays: [A, B], cursorPoint: B.center })`
314314+ → expect `reposition` to top of B.
315315+316316+After Phase 6, the in-place test file should have ≥20 cases covering every
317317+mode + every realistic display-change scenario.
318318+319319+---
320320+321321+## Risk register
322322+323323+- **Session restore.** Restored windows currently flow through the same
324324+ window-open path. Phase 2 has to derive `placement` for restored windows
325325+ too. Risk: a restored cmd panel without `center: true` in saved options
326326+ comes back as `manual`. Mitigation: window-open path's `placement`
327327+ derivation runs even on restore — the restore caller still passes the
328328+ appropriate flags.
329329+330330+- **Tests can't catch macOS native window migration.** When a display is
331331+ unplugged, macOS itself moves windows; that happens before our code runs.
332332+ Phase 1's pure function can't simulate that. Phase 6 manual repro is the
333333+ only check — accept this gap and document it.
334334+335335+- **`tile-ipc.ts` is large and has its own quirks.** Phase 5 may surface
336336+ v2-tile-specific assumptions (e.g., trustedBuiltin tokens minted before
337337+ bounds are computed). Keep Phase 5 isolated so it can be reverted without
338338+ losing Phases 1–4.
339339+340340+- **Phase 3 deletes `isWindowAccessibleNow` and `repositionOnCursorDisplay`.**
341341+ These were just added in the most recent fixes. Confirm via grep that no
342342+ call site outside `ipc.ts` imports them before deleting.
343343+344344+---
345345+346346+## What this prevents
347347+348348+Every regression that has shipped in this area in recent months:
349349+350350+1. cmd panel staying on disconnected external monitor → caught by the
351351+ `centered` + stranded test.
352352+2. Page-host opening on wrong display after primary swap → caught by the
353353+ `cursor-display-fallback` + stranded test.
354354+3. Slides anchoring to stale `window.screen` coords → can't happen; renderer
355355+ no longer does the math (Phase 4).
356356+4. Display-watcher only rescuing <30%-overlap orphans → replaced by per-window
357357+ `computePlacement` pass that respects each window's intent.
358358+359359+Future regressions in this area become single failing unit tests.