From cbe809cd8f3ffa8b668b9fc90f9938bc6504ef5b Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Fri, 14 Aug 2026 00:43:24 +0200 Subject: [PATCH] fix(window-kernel): the overlay follow rule, restored as stored facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swap left the shared chrome overlay with no way to appear. Making it visible was `applyOverlayFollow`'s job alone — the overlay window is created hidden and only ever appeared through the reducer's non-activating show — and that arm became a no-op. `AttachOverlay` carried neither a show intent nor a frame, so after the swap no window had chrome, for the whole session. Three more consequences rode along. A retarget never reframed, because the follow rule fired only on a host bounds diff and an attach does not move the host. When it did fire it wrote the raw host rect rather than the expanded overlay frame, losing the gutter and trigger-zone band. And a host entering fullscreen no longer hid the overlay; the bounds diff re-bounded it over the fullscreen window instead. The overlay's visibility and frame are now stored facts that `evolve` writes and `react` diffs, rather than anything derived from the attach event's identity — `react` is a projection of the transition and may not branch on which event caused it. The frame diff is emitted before the visibility loop so bounds always precede a show, and the op token is stamped only on a genuine reveal. The frame stays data computed by the caller. Expanding a host rect into the overlay frame needs a work-area read, which is platform data the kernel cannot obtain under its zero-value-import rule; recomputing the gutter math inside the kernel would duplicate the helper without removing that dependency. The attach target now admits quick-view. Peeks and slides open as page hosts specifically to get the shared address bar and have no navbar of their own, so excluding them painted chrome around whichever page sat behind them, or left them with no address bar at all. The docblock argued the exclusion kept the overlay off a peek while the page beneath went chrome-less; that reasoning inverts for a frontmost peek, which is the window being worked in. --- apps/desktop/main/hybrid-overlay.ts | 7 +- apps/desktop/main/window-kernel-shadow.ts | 21 +- apps/desktop/main/window-kernel.test.ts | 228 +++++++++++++++++- apps/desktop/main/window-kernel.ts | 273 ++++++++++++++++++---- 4 files changed, 463 insertions(+), 66 deletions(-) diff --git a/apps/desktop/main/hybrid-overlay.ts b/apps/desktop/main/hybrid-overlay.ts index efedd61a..c1ceb48b 100644 --- a/apps/desktop/main/hybrid-overlay.ts +++ b/apps/desktop/main/hybrid-overlay.ts @@ -736,8 +736,11 @@ function retarget(id: number | null): void { dispatchWindowEvent({ type: 'OVERLAY_ATTACH_REQUESTED', id: overlayWmId, hostId, frame }); // Shadow-feed the kernel (window-kernel-shadow.ts) — additive, authoritative // over nothing; see that module's doc for why this can never change what - // actually happens on screen. - shadowKernelAttachOverlay(overlayWmId, hostId, getWindowStateSnapshot()); + // actually happens on screen. The SAME `frame` computed above for the old + // machine's event rides this feed too (`window-kernel.ts + // WindowRecord.overlayFrame`'s doc), so the kernel's attach/retarget + // reframes exactly as the old machine's does. + shadowKernelAttachOverlay(overlayWmId, hostId, getWindowStateSnapshot(), frame); // Tell the overlay renderer which window is now active + a state snapshot so // it fully swaps its navbar for the new window (instant paint, no flash). diff --git a/apps/desktop/main/window-kernel-shadow.ts b/apps/desktop/main/window-kernel-shadow.ts index a96739b5..a7a2be83 100644 --- a/apps/desktop/main/window-kernel-shadow.ts +++ b/apps/desktop/main/window-kernel-shadow.ts @@ -668,12 +668,19 @@ export function shadowKernelRaiseWindow(id: WindowId, oldSnapshot: StateSnapshot * `shadowExecute` (a rejected stale `expectedVersion`, or a `hostId`/ * `overlayId` `decide` doesn't recognize, is logged, never thrown into the * caller), ending with the same `compareAfterFeed` tail. + * + * `frame` is the freshly expanded overlay frame for `hostId`, computed at the + * caller's dispatch site the same way `OVERLAY_ATTACH_REQUESTED.frame` is + * (`hybrid-overlay.ts retarget()`) — see `window-kernel.ts + * WindowRecord.overlayFrame`'s doc for why the kernel takes this as data + * rather than computing it. Omitted on a detach. */ -export function shadowKernelAttachOverlay(overlayId: WindowId, hostId: WindowId | null, oldSnapshot: StateSnapshot): void { +export function shadowKernelAttachOverlay(overlayId: WindowId, hostId: WindowId | null, oldSnapshot: StateSnapshot, frame?: Rectangle): void { shadowExecute({ type: 'AttachOverlay', overlayId, hostId, + ...(frame !== undefined ? { frame } : {}), expectedVersion: getShadowKernelExecutor().getState().version, }); compareAfterFeed(oldSnapshot); @@ -879,9 +886,17 @@ export function shadowKernelSetFullscreen(id: WindowId, desired: boolean, oldSna * The old machine's `BOUNDS_CHANGED.bounds` and the kernel's own `Rectangle` * are the SAME type (`window-state.ts` imports `Rectangle` from * `window-kernel.ts`), so no conversion is needed at this boundary. + * + * `overlayFrame` mirrors `BOUNDS_CHANGED.overlayFrame` — the freshly + * expanded overlay frame for `id`, when `id` is hosting the shared chrome + * overlay (`electron-window-backend.ts attachNativeListeners`'s `emitBounds` + * closure already computes this for every page-host's bounds report for the + * old machine's own dispatch; it needs threading through to THIS call too). + * See `window-kernel.ts WindowRecord.overlayFrame`'s doc for why the frame + * rides here as data rather than being computed inside the kernel. */ -export function shadowKernelBoundsChanged(id: WindowId, bounds: Rectangle, oldSnapshot: StateSnapshot): void { - shadowDispatch({ type: 'BoundsChanged', id, bounds }); +export function shadowKernelBoundsChanged(id: WindowId, bounds: Rectangle, oldSnapshot: StateSnapshot, overlayFrame?: Rectangle): void { + shadowDispatch({ type: 'BoundsChanged', id, bounds, ...(overlayFrame !== undefined ? { overlayFrame } : {}) }); compareAfterFeed(oldSnapshot); } diff --git a/apps/desktop/main/window-kernel.test.ts b/apps/desktop/main/window-kernel.test.ts index b8d40e6a..7c0658ec 100644 --- a/apps/desktop/main/window-kernel.test.ts +++ b/apps/desktop/main/window-kernel.test.ts @@ -1892,31 +1892,52 @@ describe('window-kernel: overlay attachment', () => { assert.strictEqual(overlayForHost(s, hostB), null); }); - it('an attached overlay follows its host BoundsChanged; a detached one does not', () => { + it('an attached overlay follows its host to the freshly EXPANDED overlay frame, never the raw host rect; a detached one does not follow at all', () => { const registered = fold(registerHostsAndOverlay(), initialState()); const attached = fold( [{ type: 'OverlayAttached', overlayId, hostId: hostA }], registered, ); + // `overlayFrame` stands in for `expandToOverlayFrame(bounds, workArea)` — + // deliberately NOT equal to `bounds`, so the assertion below can tell the + // two apart. The dispatch site (`electron-window-backend.ts emitBounds`) + // computes it; the kernel only stores what it is given. const bounds: Rectangle = { x: 10, y: 20, width: 300, height: 400 }; - const boundsEv: Event = { type: 'BoundsChanged', id: hostA, bounds }; + const overlayFrame: Rectangle = { x: -8, y: -44, width: 316, height: 452 }; + const boundsEv: Event = { type: 'BoundsChanged', id: hostA, bounds, overlayFrame }; const nextAttached = evolve(attached, boundsEv); const attachedEffects = react(attached, boundsEv, nextAttached); assert.ok( - attachedEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(bounds)), - 'an attached overlay must follow its host bounds change', + attachedEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(overlayFrame)), + 'an attached overlay must follow its host to the EXPANDED overlay frame', + ); + assert.ok( + !attachedEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(bounds)), + 'the raw host rect must never be handed to the overlay directly — that was the gutter/trigger-zone-losing bug', + ); + + // A BoundsChanged naming no `overlayFrame` — the dispatch site could not + // read a work area, or this window has no overlay attached — reframes + // nothing: there is no new frame to follow to. + const noFrameEv: Event = { type: 'BoundsChanged', id: hostA, bounds }; + const noFrameNext = evolve(attached, noFrameEv); + const noFrameEffects = react(attached, noFrameEv, noFrameNext); + assert.ok( + !noFrameEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId), + 'no overlayFrame on the event means nothing new to reframe to', ); - // Detach, then move hostA's bounds again — no follow this time. + // Detach, then move hostA's bounds again, WITH a frame — still no follow. const detached = evolve(attached, { type: 'OverlayAttached', overlayId, hostId: null }); const bounds2: Rectangle = { x: 1, y: 2, width: 3, height: 4 }; - const boundsEv2: Event = { type: 'BoundsChanged', id: hostA, bounds: bounds2 }; + const overlayFrame2: Rectangle = { x: -7, y: -42, width: 19, height: 46 }; + const boundsEv2: Event = { type: 'BoundsChanged', id: hostA, bounds: bounds2, overlayFrame: overlayFrame2 }; const nextDetached = evolve(detached, boundsEv2); const detachedEffects = react(detached, boundsEv2, nextDetached); assert.ok( !detachedEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId), - 'a detached overlay must not follow bounds changes', + 'a detached overlay must not follow bounds changes even when a frame is given', ); }); @@ -1982,6 +2003,181 @@ describe('window-kernel: overlay attachment', () => { }); }); +// ── Overlay follow: visibility and frame ──────────────────────────────────── +// +// Ports `window-state.ts applyOverlayFollow`'s full rule set (via +// `overlayFollowPatch`, `window-kernel.ts`): a non-normal host always hides +// its attached overlay; a normal host takes the given frame and reveals the +// overlay while the app is active. `WindowShown` is fired for neither host in +// these scenes on purpose — a hand-written scene omitting it would leave +// `visible` false and silently exclude the window from content predicates, +// but the overlay-follow rule reads a host's `fsMode`/registration facts, not +// its own visibility, so the hosts here are deliberately left unshown to keep +// each scene to the minimum that exercises the rule under test. + +describe('window-kernel: overlay follow — visibility and frame', () => { + const registerHostsAndOverlay = (): Event[] => [ + { type: 'WindowRegistered', id: wid(1), kind: 'page-host', layer: 0, canBecomeKey: true, acceptsInput: true, class: 'primary' }, + { type: 'WindowRegistered', id: wid(2), kind: 'page-host', layer: 0, canBecomeKey: true, acceptsInput: true, class: 'primary' }, + { type: 'WindowRegistered', id: wid(3), kind: 'browser', layer: 1, canBecomeKey: false, acceptsInput: false, class: 'overlay' }, + ]; + const overlayId = wid(3); + const hostA = wid(1); + const hostB = wid(2); + /** Stand-ins for `expandToOverlayFrame(bounds, workArea)` — deliberately NOT + * equal to any raw host bounds used alongside them, so an assertion can + * tell "the expanded frame" apart from "the raw host rect". */ + const frameA: Rectangle = { x: -8, y: -44, width: 316, height: 452 }; + const frameB: Rectangle = { x: 92, y: 56, width: 336, height: 468 }; + + /** The app active, both hosts + the overlay registered, nothing attached yet. */ + const active = (): State => fold([...registerHostsAndOverlay(), { type: 'AppActivated' }], initialState()); + + it('attach shows the overlay when the host is normal and the app is active — the frame lands before the reveal', () => { + const state = active(); + assert.strictEqual(state.order.find((w) => w.id === overlayId)?.visible, false, 'precondition: not yet shown'); + + const ev: Event = { type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }; + const next = evolve(state, ev); + const effects = react(state, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.visible, true); + assert.strictEqual(overlay.overlayFor, hostA); + assert.deepStrictEqual(overlay.overlayFrame, frameA); + assert.ok(next.opSeqCounter > state.opSeqCounter, 'a genuine show mints a token'); + assert.strictEqual(overlay.lastOpSeq, next.opSeqCounter); + + const boundsIdx = effects.findIndex( + (e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(frameA), + ); + const showIdx = effects.findIndex((e) => e.type === 'ShowWindow' && e.id === overlayId && e.opSeq === next.opSeqCounter); + assert.notStrictEqual(boundsIdx, -1, 'the overlay is framed'); + assert.notStrictEqual(showIdx, -1, 'the overlay is shown, carrying the op token evolve stamped'); + assert.ok(boundsIdx < showIdx, 'the frame lands before the reveal, so it never paints at a stale rect'); + }); + + it('retarget re-frames the ALREADY-visible overlay onto the new host — no second ShowWindow, no new op token', () => { + const attached = fold([{ type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }], active()); + assert.strictEqual(attached.order.find((w) => w.id === overlayId)?.visible, true, 'precondition: shown on hostA'); + + const ev: Event = { type: 'OverlayAttached', overlayId, hostId: hostB, frame: frameB }; + const next = evolve(attached, ev); + const effects = react(attached, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.overlayFor, hostB); + assert.deepStrictEqual(overlay.overlayFrame, frameB); + assert.strictEqual(overlay.visible, true, 'still shown — a retarget is not a fresh reveal'); + assert.strictEqual(next.opSeqCounter, attached.opSeqCounter, 'no focus-family effect — nothing to stamp a token for'); + + assert.ok( + effects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(frameB)), + 'the overlay follows onto the new host at its frame', + ); + assert.ok(!effects.some((e) => e.type === 'ShowWindow' && e.id === overlayId), 'already visible — no re-show'); + }); + + it('detach hides the overlay', () => { + const attached = fold([{ type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }], active()); + assert.strictEqual(attached.order.find((w) => w.id === overlayId)?.visible, true, 'precondition: shown'); + + const ev: Event = { type: 'OverlayAttached', overlayId, hostId: null }; + const next = evolve(attached, ev); + const effects = react(attached, ev, next); + + assert.strictEqual(next.order.find((w) => w.id === overlayId)?.overlayFor, undefined); + assert.ok(effects.some((e) => e.type === 'HideWindow' && e.id === overlayId)); + }); + + it('a host entering fullscreen hides its attached overlay, without severing the attachment', () => { + const attached = fold([{ type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }], active()); + assert.strictEqual(attached.order.find((w) => w.id === overlayId)?.visible, true, 'precondition: shown'); + + const ev: Event = { type: 'OsFullscreenEntered', id: hostA }; + const next = evolve(attached, ev); + const effects = react(attached, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.visible, false); + assert.strictEqual(overlay.overlayFor, hostA, 'still attached — only visibility moved, the same shape a fullscreen-hidden overlay had before this rule existed'); + assert.ok(effects.some((e) => e.type === 'HideWindow' && e.id === overlayId)); + assert.ok(!effects.some((e) => e.type === 'SetBounds' && e.id === overlayId), 'a fullscreen hide never touches the frame'); + assert.strictEqual(next.opSeqCounter, attached.opSeqCounter, 'a hide is never a focus-family effect'); + }); + + it('a host bounds change re-frames its attached, ALREADY-visible overlay — no extra ShowWindow', () => { + const attached = fold([{ type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }], active()); + assert.strictEqual(attached.order.find((w) => w.id === overlayId)?.visible, true, 'precondition: shown'); + + const movedBounds: Rectangle = { x: 50, y: 60, width: 300, height: 400 }; + const movedFrame: Rectangle = { x: 42, y: 16, width: 316, height: 452 }; + const ev: Event = { type: 'BoundsChanged', id: hostA, bounds: movedBounds, overlayFrame: movedFrame }; + const next = evolve(attached, ev); + const effects = react(attached, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.deepStrictEqual(overlay.overlayFrame, movedFrame); + assert.strictEqual(overlay.visible, true, 'still visible — a reframe is not a re-show'); + assert.ok( + effects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(movedFrame)), + 'the overlay follows to the freshly expanded frame', + ); + assert.ok(!effects.some((e) => e.type === 'ShowWindow' && e.id === overlayId), 'already visible — no re-show'); + assert.strictEqual(next.opSeqCounter, attached.opSeqCounter, 'a reframe alone mints no op token'); + }); + + it('leaving fullscreen re-reveals the overlay at its LAST frame — no SetBounds, only a ShowWindow carrying a fresh op token', () => { + const hidden = fold( + [ + { type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }, + { type: 'OsFullscreenEntered', id: hostA }, + ], + active(), + ); + assert.strictEqual(hidden.order.find((w) => w.id === overlayId)?.visible, false, 'precondition: hidden by fullscreen'); + + const ev: Event = { type: 'OsFullscreenLeft', id: hostA }; + const next = evolve(hidden, ev); + const effects = react(hidden, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.visible, true); + assert.deepStrictEqual(overlay.overlayFrame, frameA, 'never moved while hidden — the same frame as before the hide'); + assert.ok(next.opSeqCounter > hidden.opSeqCounter, 'a genuine show mints a token'); + assert.strictEqual(overlay.lastOpSeq, next.opSeqCounter); + + assert.ok( + effects.some((e) => e.type === 'ShowWindow' && e.id === overlayId && e.opSeq === next.opSeqCounter), + 'the overlay is re-shown, carrying the op token evolve stamped', + ); + assert.ok(!effects.some((e) => e.type === 'SetBounds' && e.id === overlayId), 'no rect write — the overlay was never moved while hidden'); + }); + + it('a non-normal host at attach time hides rather than reveals, even though a frame and an active app are both given', () => { + // The mid-transition case: a retarget landing while the new host is + // already `entering`/`leaving`/`fullscreen` must not paint chrome over + // it, regardless of `frame`/`appActive` — ported from + // `applyOverlayFollow`'s early return before its `SET_BOUNDS` line. + const midTransition = fold( + [...registerHostsAndOverlay(), { type: 'AppActivated' }, { type: 'OsFullscreenEntered', id: hostA }], + initialState(), + ); + assert.notStrictEqual(midTransition.order.find((w) => w.id === hostA)?.fsMode.name, 'normal', 'precondition: hostA is fullscreen'); + + const ev: Event = { type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }; + const next = evolve(midTransition, ev); + const effects = react(midTransition, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.overlayFor, hostA, 'the attachment is still recorded'); + assert.strictEqual(overlay.visible, false); + assert.strictEqual(overlay.overlayFrame, undefined, 'no frame is stored — the early return never reaches the frame write'); + assert.ok(!effects.some((e) => e.type === 'ShowWindow' && e.id === overlayId)); + assert.ok(!effects.some((e) => e.type === 'SetBounds' && e.id === overlayId)); + }); +}); + // ── Focus provenance: cause, and isStaleSelfEcho ──────────────────────────── describe('window-kernel: focus provenance (cause) and isStaleSelfEcho', () => { @@ -3574,21 +3770,23 @@ describe('window-kernel: topmostBaseLayerPageHost', () => { { type: 'WindowContentOrderable', id }, ]; - it('a floating quick-view above a base-layer page host does not shadow it — the chrome frames the page, not the peek', () => { + it('a topmost quick-view IS the attach target — the chrome frames the peek, not the base-layer page beneath it', () => { const base = wid(1); const quickView = wid(2); const state = fold([ registerPageHost(base), ...shown(base), - // Registered later, in the floating band — a peek opened over the page. - registerPageHost(quickView, { layer: FLOATING, role: 'quick-view' }), + // Registered later, at the SAME base layer — real peeks/slides declare + // no `alwaysOnTop` (`izui-roles.ts deriveWindowLayer`), so they land at + // `BASE_LAYER` like any ordinary page, never in a floating band. + registerPageHost(quickView, { role: 'quick-view' }), ...shown(quickView), ]); assert.deepStrictEqual(state.order.map((w) => w.id), [base, quickView], 'the quick-view sits above `base` in `order`'); assert.strictEqual( topmostBaseLayerPageHost(state)?.id, - base, - 'kind alone admits the peek too — the role clause is what excludes it', + quickView, + 'a frontmost peek is the window the user is working in — peeks/slides use the shared overlay for their address bar (task ff3b18ba) and ship no navbar of their own', ); }); @@ -3598,6 +3796,12 @@ describe('window-kernel: topmostBaseLayerPageHost', () => { assert.strictEqual(topmostBaseLayerPageHost(state), null, 'array position alone would have found it — the layer clause is what excludes it'); }); + it('the layer clause still excludes a quick-view outside the base layer, even though the role is now admitted', () => { + const floatingQuickView = wid(1); + const state = fold([registerPageHost(floatingQuickView, { layer: FLOATING, role: 'quick-view' }), ...shown(floatingQuickView)]); + assert.strictEqual(topmostBaseLayerPageHost(state), null, 'admitting the role did not also drop the layer requirement'); + }); + it('no eligible page-host reads null, even with other windows on screen', () => { const browserWin = wid(1); const utilityHost = wid(2); diff --git a/apps/desktop/main/window-kernel.ts b/apps/desktop/main/window-kernel.ts index 20c5db03..042eed02 100644 --- a/apps/desktop/main/window-kernel.ts +++ b/apps/desktop/main/window-kernel.ts @@ -248,6 +248,31 @@ export interface WindowRecord { */ overlayFor?: WindowId; + /** + * ATTACHED-OVERLAY TARGET FRAME. Set on an OVERLAY record alongside + * `overlayFor`: the frame the overlay should currently occupy, taken + * verbatim from whichever call supplied one (`AttachOverlay.frame`, + * `BoundsChanged.overlayFrame`) — the kernel never computes this itself. + * `expandToOverlayFrame` (`overlay-frame.ts`) needs a work-area read + * (`screen.getDisplayNearestPoint`), which is platform data this + * zero-value-import file cannot ask for, so the frame rides on the + * command/event as data, computed at the dispatch site that already knows + * the work area — the same design `window-state.ts + * OVERLAY_ATTACH_REQUESTED.frame` / `BOUNDS_CHANGED.overlayFrame` used. + * + * DISTINCT FROM `bounds`, deliberately: `bounds` is a window's own + * OS-mirrored rect, written only by a genuine `BoundsChanged` naming THAT + * window. `overlayFrame` is a TARGET this window (the overlay) has not + * necessarily reached on screen yet — which is exactly what makes it a + * STORED FACT `react` can diff into a `SetBounds` effect (see `react`'s + * "Overlay follow — frame" rule) without reading `ev.type`: the fact + * exists so `react` never has to ask which event produced it, only + * whether it moved. `undefined` until the first frame is ever given, and + * untouched by a hide (a fullscreen-hidden overlay keeps its last frame, + * so re-revealing it needs no new `SetBounds`). + */ + overlayFrame?: Rectangle; + /** * The newest `opSeq` stamped on a focus-family effect (`ShowWindow` / * `RaiseWindow`) issued for this window — what `isStaleSelfEcho` compares @@ -731,8 +756,20 @@ export type Event = * `Maximize` / `Unmaximize` below reuse this same event shape rather than * inventing a second bounds-writing fact, under the dispatch-is-synchronous * contract described on the field. + * + * `overlayFrame` is the freshly expanded overlay frame for THIS window, + * when it is hosting the shared chrome overlay — ported from + * `window-state.ts BOUNDS_CHANGED.overlayFrame`. Computed at the dispatch + * site the same way `AttachOverlay.frame` is (see + * `WindowRecord.overlayFrame`'s doc for why): the backend already computes + * it for every page-host's bounds report and only needs to stop dropping + * it at the kernel's edge (`window-kernel-shadow.ts + * shadowKernelBoundsChanged`). Absent when this window has no overlay + * attached, or the dispatch site could not read a work area — `evolve` + * treats an absent frame as "nothing new to reframe to", same as + * `AttachOverlay` with no `frame`. */ - | { type: 'BoundsChanged'; id: WindowId; bounds: Rectangle } + | { type: 'BoundsChanged'; id: WindowId; bounds: Rectangle; overlayFrame?: Rectangle } /** * The display layout changed (ported from `window-state.ts * DISPLAY_CHANGED`) — one already-computed target rect per affected @@ -783,11 +820,20 @@ export type Event = * As an event, it is a fact already decided elsewhere and can never be * rejected here: `evolve` normalizes a `hostId` that isn't registered, or * that names the overlay itself, into a detach rather than storing it — - * see `evolve`'s `OverlayAttached` case. The follow itself is `react`'s - * job, driven off the host's next `BoundsChanged`, never a live query at - * this event's own dispatch time. + * see `evolve`'s `OverlayAttached` case. The follow (reveal, hide, reframe) + * is decided IN `evolve`, from the host's CURRENT `fsMode` and + * `state.appActive` — never a live query at some later point, and never a + * branch `react` has to make: `evolve` writes the result onto the overlay's + * `visible`/`overlayFrame` fields, and `react` only diffs them. + * + * `frame` is the freshly expanded overlay frame for the NEW host, computed + * at the dispatch site the same way `BoundsChanged.overlayFrame` is (see + * `WindowRecord.overlayFrame`'s doc) — ported from `window-state.ts + * OVERLAY_ATTACH_REQUESTED.frame`. Absent on a detach, or when the + * dispatch site could not read the host's bounds between the retarget + * signal and this call. */ - | { type: 'OverlayAttached'; overlayId: WindowId; hostId: WindowId | null } + | { type: 'OverlayAttached'; overlayId: WindowId; hostId: WindowId | null; frame?: Rectangle } /** * A persisted bottom -> top stack order was imported (`decide`'s translation * of `ImportStackOrder`; ported from `window-state.ts @@ -942,8 +988,13 @@ export type Command = * is detach. One transition, no separate detach command (see the doc on * `WindowRecord.overlayFor`). `decide` rejects if either named window is * absent from `order`. + * + * `frame` carries the newly expanded overlay frame for `hostId`, computed + * at the caller's dispatch site — see `WindowRecord.overlayFrame`'s doc for + * why the kernel takes this as data rather than computing it. Meaningless + * on a detach (`hostId: null`), and safely ignored there. */ - | { type: 'AttachOverlay'; overlayId: WindowId; hostId: WindowId | null; expectedVersion: number } + | { type: 'AttachOverlay'; overlayId: WindowId; hostId: WindowId | null; frame?: Rectangle; expectedVersion: number } /** * Restore a persisted bottom -> top stack order (ported from * `window-state.ts STACK_ORDER_IMPORTED`) — the session-restore ordering @@ -1289,6 +1340,40 @@ function withPendingProjectionFlushed(record: WindowRecord): WindowRecord { return { ...record, bounds: record.pendingProjectedBounds, pendingProjectedBounds: null }; } +// ───────────────────────────────────────────────────────────────────────────── +// Overlay follow (§ WindowRecord.overlayFor/overlayFrame) — the decision half +// of `window-state.ts applyOverlayFollow`, ported WITHOUT its effects: it +// returns the record patch an evolve arm merges onto an attached overlay, +// rather than pushing effects itself (effects are `react`'s job, from the +// diff those patches leave behind). Shared by every evolve arm that can move +// an attached overlay's visibility or frame (`OverlayAttached`, +// `BoundsChanged`, `OsFullscreenEntered`, `OsFullscreenLeft`) so the rule — +// a non-normal host always hides; a normal host takes the given frame and +// reveals while the app is active — lives in exactly one place. Total and +// reads only the values it is given, same as every other evolve helper. +// ───────────────────────────────────────────────────────────────────────────── + +/** The record patch `overlayFollowPatch` can produce — see that function. */ +type OverlayFollowPatch = Partial>; + +function overlayFollowPatch(overlay: WindowRecord, hostNormal: boolean, appActive: boolean, frame: Rectangle | undefined): OverlayFollowPatch { + if (!hostNormal) { + // Ported verbatim: a non-normal host hides and returns — no frame write, + // even when one was given, and no reveal. + return overlay.visible ? { visible: false } : {}; + } + const patch: OverlayFollowPatch = {}; + if (frame !== undefined) patch.overlayFrame = frame; + if (appActive && !overlay.visible) { + patch.visible = true; + // `hiddenByAppBlur` can never be true on a visible window (checked by + // `window-kernel-properties.test.ts checkInvariants`) — cleared here the + // same way `WindowShown`'s arm clears it on any genuine show. + if (overlay.hiddenByAppBlur === true) patch.hiddenByAppBlur = false; + } + return patch; +} + // ───────────────────────────────────────────────────────────────────────────── // evolve — TOTAL. No guards. Cannot throw. Reads nothing but its two // arguments. Every event bumps `version`. @@ -1666,8 +1751,9 @@ export function evolve(state: State, ev: Event): State { // overlays outright: an attached overlay's visibility belongs to its // attachment, so it is driven here and never dismissed. An already-hidden // overlay is left alone, which is what keeps a host in fullscreen (whose - // overlay `react`'s follow rule already drove off screen) from having its - // chrome revealed by the next activation. + // overlay the `OsFullscreenEntered` evolve arm already drove off screen, + // via `overlayFollowPatch`) from having its chrome revealed by the next + // activation. // // No `opSeq` is stamped, exactly as the `AppResignSettled` sweep stamps // none: `lastOpSeq` tracks focus-family ops and `HideWindow` carries no @@ -1886,9 +1972,21 @@ export function evolve(state: State, ev: Event): State { } case 'OsFullscreenEntered': { + // OVERLAY FOLLOW (ported from `window-state.ts applyOverlayFollow`'s + // fullscreen branch): `nextFsModeForOsEntered` never settles a host + // back to `normal` (every one of its arms lands on `entering`, + // `leaving`, or `fullscreen` — see that function), so a host entering + // fullscreen always takes its attached overlay off screen. No opSeq + // stamp: a hide is never a focus-family effect. + const overlay = overlayForHost(state, ev.id); + const patch = overlay !== null ? overlayFollowPatch(overlay, false, state.appActive, undefined) : null; return { ...state, - order: state.order.map((w) => (w.id === ev.id ? { ...w, fsMode: nextFsModeForOsEntered(w.fsMode) } : w)), + order: state.order.map((w) => { + if (w.id === ev.id) return { ...w, fsMode: nextFsModeForOsEntered(w.fsMode) }; + if (patch !== null && overlay !== null && w.id === overlay.id) return { ...w, ...patch }; + return w; + }), version: state.version + 1, }; } @@ -1896,11 +1994,31 @@ export function evolve(state: State, ev: Event): State { case 'OsFullscreenLeft': { // Can newly clear the fsMode axis of a held DisplayChanged projection // (several of its arms settle onto 'normal') — flush if visible too. + // + // OVERLAY FOLLOW: the reverse of `OsFullscreenEntered` above — settling + // back to `normal` re-reveals the attached overlay while the app is + // active, at its LAST frame (no `frame` is given here, mirroring + // `window-state.ts applyOverlayFollow`'s "never moved while hidden, so + // no rect needed" case). `settlesNormal` reads the host's PRIOR + // (`state`) record, since this arm computes the settle itself rather + // than reading it back off `next` — `evolve` may read only its own two + // arguments. + const hostBefore = state.order.find((w) => w.id === ev.id); + const settlesNormal = hostBefore !== undefined && nextFsModeForOsLeft(hostBefore.fsMode).name === 'normal'; + const overlay = overlayForHost(state, ev.id); + const patch = overlay !== null ? overlayFollowPatch(overlay, settlesNormal, state.appActive, undefined) : null; + const issuesShow = patch !== null && patch.visible === true; + const opSeq = issuesShow ? state.opSeqCounter + 1 : state.opSeqCounter; return { ...state, - order: state.order.map((w) => - w.id === ev.id ? withPendingProjectionFlushed({ ...w, fsMode: nextFsModeForOsLeft(w.fsMode) }) : w, - ), + order: state.order.map((w) => { + if (w.id === ev.id) return withPendingProjectionFlushed({ ...w, fsMode: nextFsModeForOsLeft(w.fsMode) }); + if (patch !== null && overlay !== null && w.id === overlay.id) { + return { ...w, ...patch, ...(issuesShow ? { lastOpSeq: opSeq } : {}) }; + } + return w; + }), + opSeqCounter: opSeq, version: state.version + 1, }; } @@ -1939,9 +2057,28 @@ export function evolve(state: State, ev: Event): State { // `decide`'s translation of SetBounds/Maximize/Unmaximize (see the // event doc). Mirror only — no other field, no effects computed here // (effects are `react`'s job, from the diff). + // + // OVERLAY FOLLOW (ported from `window-state.ts applyOverlayFollow`, + // §5 step 9's "follow driven by boundsChanged"): the host's own bounds + // write above is untouched by this — only its ATTACHED overlay (if + // any) reframes, off `ev.overlayFrame` (the dispatch site's freshly + // expanded frame, computed alongside the raw bounds — see the event's + // own doc), and reveals when the host is normal and the app is active. + const host = state.order.find((w) => w.id === ev.id); + const overlay = overlayForHost(state, ev.id); + const patch = overlay !== null && host !== undefined ? overlayFollowPatch(overlay, host.fsMode.name === 'normal', state.appActive, ev.overlayFrame) : null; + const issuesShow = patch !== null && patch.visible === true; + const opSeq = issuesShow ? state.opSeqCounter + 1 : state.opSeqCounter; return { ...state, - order: state.order.map((w) => (w.id === ev.id ? { ...w, bounds: ev.bounds } : w)), + order: state.order.map((w) => { + if (w.id === ev.id) return { ...w, bounds: ev.bounds }; + if (patch !== null && overlay !== null && w.id === overlay.id) { + return { ...w, ...patch, ...(issuesShow ? { lastOpSeq: opSeq } : {}) }; + } + return w; + }), + opSeqCounter: opSeq, version: state.version + 1, }; } @@ -2023,9 +2160,24 @@ export function evolve(state: State, ev: Event): State { // a valid host. const resolvedHostId: WindowId | undefined = ev.hostId !== null && ev.hostId !== ev.overlayId && state.order.some((w) => w.id === ev.hostId) ? ev.hostId : undefined; + // OVERLAY FOLLOW (ported from `window-state.ts applyOverlayFollow`, via + // `overlayFollowPatch`): attach and retarget drive the SAME + // reveal/reframe decision the bounds- and fullscreen-driven follow + // rules elsewhere in this switch drive. Detach (`host === undefined`) + // touches only the relation above; `react`'s structural `overlayFor` + // diff (its "Overlay detach" rule) is what drives it off screen, same + // as before this change. + const host = resolvedHostId !== undefined ? state.order.find((w) => w.id === resolvedHostId) : undefined; + const overlay = state.order.find((w) => w.id === ev.overlayId); + const patch = host !== undefined && overlay !== undefined ? overlayFollowPatch(overlay, host.fsMode.name === 'normal', state.appActive, ev.frame) : {}; + const issuesShow = patch.visible === true; + const opSeq = issuesShow ? state.opSeqCounter + 1 : state.opSeqCounter; return { ...state, - order: state.order.map((w) => (w.id === ev.overlayId ? { ...w, overlayFor: resolvedHostId } : w)), + order: state.order.map((w) => + w.id === ev.overlayId ? { ...w, overlayFor: resolvedHostId, ...patch, ...(issuesShow ? { lastOpSeq: opSeq } : {}) } : w, + ), + opSeqCounter: opSeq, version: state.version + 1, }; } @@ -2220,6 +2372,21 @@ export function evolve(state: State, ev: Event): State { export function react(prev: State, ev: Event, next: State): Effect[] { const effects: Effect[] = []; + // Overlay follow — FRAME (§ WindowRecord.overlayFrame): a structural diff + // of the stored target frame `evolve` writes (`OverlayAttached`, + // `BoundsChanged`, `OsFullscreenLeft` — see `overlayFollowPatch`), reading + // only `prev`/`next` and never `ev.type`. PLACED FIRST, ahead of the + // visibility loop just below, so a reveal never paints the overlay at a + // stale frame — the kernel's structural-diff form of `window-state.ts + // applyOverlayFollow`'s `SET_BOUNDS`-before-`SHOW_WINDOW` ordering. + for (const nextRecord of next.order) { + const prevRecord = prev.order.find((w) => w.id === nextRecord.id); + if (prevRecord === undefined) continue; + if (nextRecord.overlayFrame !== undefined && !boundsEqual(prevRecord.overlayFrame, nextRecord.overlayFrame)) { + effects.push({ type: 'SetBounds', id: nextRecord.id, bounds: nextRecord.overlayFrame }); + } + } + // Visibility: a record present in both states whose `visible` flipped. // (A closed window vanishes from `next.order` entirely — no HideWindow; // its dismissal is the CloseWindow diff below instead.) @@ -2375,20 +2542,6 @@ export function react(prev: State, ev: Event, next: State): Effect[] { } } - // Overlay follow (§ WindowRecord.overlayFor): an attached overlay's bounds - // effect mirrors its HOST's bounds diff — never a live query, never a - // branch on `ev.type`. A detached overlay (`overlayFor` undefined) has no - // host to diff against and is skipped. - for (const nextRecord of next.order) { - if (nextRecord.overlayFor === undefined) continue; - const prevHost = prev.order.find((w) => w.id === nextRecord.overlayFor); - const nextHost = next.order.find((w) => w.id === nextRecord.overlayFor); - if (nextHost === undefined) continue; - if (nextHost.bounds !== undefined && !boundsEqual(prevHost?.bounds, nextHost.bounds)) { - effects.push({ type: 'SetBounds', id: nextRecord.id, bounds: nextHost.bounds }); - } - } - // Overlay detach (an AttachOverlay retarget/detach, or the host closing): // a structural diff of `overlayFor` going from set -> unset. `evolve` // never flips `visible` for this, so a still-visible overlay must be @@ -2693,7 +2846,9 @@ export function decide(state: State, cmd: Command): { events: Event[] } | { reje if (cmd.hostId !== null && !state.order.some((w) => w.id === cmd.hostId)) { return { rejected: 'no-such-window' }; } - return { events: [{ type: 'OverlayAttached', overlayId: cmd.overlayId, hostId: cmd.hostId }] }; + return { + events: [{ type: 'OverlayAttached', overlayId: cmd.overlayId, hostId: cmd.hostId, ...(cmd.frame !== undefined ? { frame: cmd.frame } : {}) }], + }; } // ImportStackOrder carries `order`, not `id` — handled here, before the @@ -3098,25 +3253,45 @@ export function isFrontEligibleWindow(record: WindowRecord): boolean { * `layer` (see `topmost`'s own doc); this predicate holds that contract with * two clauses, neither of which is optional: * - * - a role in `CONTENT_ROLES` `kind: 'page-host'` alone is not enough to - * mean "a base-layer page". `ipc.ts - * assembleHybridPageHost` registers EVERY - * hybrid page host with `kind: 'page-host'`, - * including peeks and slides, which register - * `role: 'quick-view'`. Without this clause - * the predicate admits a floating quick-view, - * and the overlay frames a peek while the - * base-layer page underneath it sits - * chrome-less. - * - `layer === BASE_LAYER` a page-host is not confined to the base - * layer — one can sit in a floating band - * behind the cmd panel, a peek, or a slide, - * alongside base-layer page-hosts. `order` is - * insertion- and movement-ordered, never - * layer-sorted, so without this clause array - * position and visual stacking diverge with - * no error path — the topmost `order` entry - * need not be the topmost on screen. + * - a role in `CONTENT_ROLES`, + * or `'quick-view'` `kind: 'page-host'` alone is not enough to + * mean "a base-layer page" — `ipc.ts + * assembleHybridPageHost` registers EVERY + * hybrid page host with `kind: 'page-host'`, + * including peeks and slides, which + * register `role: 'quick-view'`. + * + * `'quick-view'` is ADMITTED, not excluded. + * Peeks and slides are explicitly built to + * use the shared overlay's address bar — + * `features/peeks/background.js` and + * `features/slides/background.js` both open + * with `pageHost: true` under the comment + * "page-host shell with address bar (task + * ff3b18ba)", and neither ships a navbar of + * its own. Excluding the role left a + * frontmost peek framed by nothing: with + * another page underneath, the chrome + * painted around that page instead — the + * window the user is NOT working in — and + * with the peek alone on screen, there was + * no address bar at all. A frontmost peek IS + * the window the user is working in, so this + * predicate has to name it, not whatever + * ordinary page happens to sit behind it. + * - `layer === BASE_LAYER` a page-host is not confined to the base + * layer — one can sit in a floating band + * behind the cmd panel, a peek, or a slide, + * alongside base-layer page-hosts. `order` + * is insertion- and movement-ordered, never + * layer-sorted, so without this clause array + * position and visual stacking diverge with + * no error path — the topmost `order` entry + * need not be the topmost on screen. Real + * peeks/slides land at `BASE_LAYER` too + * (`izui-roles.ts deriveWindowLayer`: they + * declare no `alwaysOnTop`), so this clause + * never excludes them on its own. * * `visible` and `orderable` are carried over from the same reasoning * `isContentWindow` and the `SetStacking` emission (`react`) already apply: a @@ -3126,7 +3301,7 @@ export function isFrontEligibleWindow(record: WindowRecord): boolean { export function topmostBaseLayerPageHost(state: State): WindowRecord | null { return topmost( state, - (r) => r.kind === 'page-host' && r.visible && r.orderable && r.layer === BASE_LAYER && roleIn(CONTENT_ROLES, r.role), + (r) => r.kind === 'page-host' && r.visible && r.orderable && r.layer === BASE_LAYER && (roleIn(CONTENT_ROLES, r.role) || r.role === 'quick-view'), ); } -- 2.51.2