src/switcher/state/WindowEventReducerPhantomSpecs.md
Covers the effects WindowEventReducer.cgsWindowListsRead emits when the CGS phantom pass flips a
window's DERIVED phantom. Specs + Tests without a same-named kernel (like RealWorldScenarios): the
subject is the reducer's effect emission, not a pure function of its own.
Driven through WindowEventReducer.reduce directly rather than the replay harness, because
.removeWindowlessPlaceholder is a display-side effect that TestReducerRunner deliberately swallows
(it has no model content) — so a scenario replay cannot observe it.
An app whose only window looks phantom is indistinguishable from an app with no windows, so a windowless
placeholder tile is added for it. When the window later un-phantoms, that placeholder is stale and must be
dropped. Four sites did this, all of them Space paths (applySpaceMembershipDelta, applyWindowSpaces).
The CGS-list pass, which owns the authoritative verdict and is the path that actually clears an Electron app's latch, did not. So Slack ended up with a real tile AND a windowless tile, permanently: "it appears twice in the list and won't change even if I wait or switch windows".
The same capture showed the second half of the bug. Slack, reopened from the Dock, keeps its window tagged invisible by CGS for seconds after it is already composited. The WindowServer ordered-in bit states that physical fact directly, so the weak CGS tag cannot flag an on-screen surface. This no longer depends on MRU or app-frontmost guesses.
Mirrors WindowEventReducerPhantomTests.swift 1:1.
The two CGS lists are a bulk answer about the wid set captured when the scan was issued. A window appended while the calls are in flight was never queried, so its absence is silence rather than the strong phantom signal. Positive list membership may still apply outside the captured set. The scan also carries an issue sequence, and an older whole answer is dropped before it can replace a newer one or erase targeted inventory updates.
queried keeps its
existing verdict..removeWindowlessPlaceholder. The regression guard for the duplicate tile.The exemption in B is only consulted when the CGS pass runs, and that pass runs on a show, landing a beat
AFTER the switcher appears. So it does not cover the fast path the reporter actually hit: open Slack, tap
the shortcut straight away, and the switcher is built while the stale verdict still hides the window that
was just focused. A committed attention decision is proof the window is real, so the verdict is cleared at
that moment instead of waiting for a pass (TrackedWindowState.clearPhantomOnFocus).
Reopening Slack from the Dock reaches the front through an activation that names only the process. The
attention model uses Slack's cached focused-window fact, or requests one bounded kAXFocusedWindow read if
no fact exists. While that answer used to bump MRU straight from the shell it bypassed the clear in C, so the
latch survived — the switcher summoned 130 ms later hid the window the user was looking at while it held slot
0, and the default pick skipped past it onto a third app. Every namer now reaches the model through
.attentionCommitted, so there is one path and one clear.
Which answers are worth anything is not decided here any more. kAXFocusedWindow reports "which window
WOULD take keys", which every app has at all times, so an answer from an app the user is not in is a fact
about that app rather than a bid for the front (#5785, a re-discovered QQ window offered as "the window you
were on before"). That gate lives in AttentionModel —
testAnAnswerFromABackgroundAppIsRecordedAndMovesNothing — and the reducer only sees what it committed.
The other half of the placeholder's lifecycle had no owner. Adding it was left to the shell's per-app sweep, which runs in the same block as the CGS fetch but BEFORE the verdicts are applied, so it judged "does this app still have a real window?" against the previous latch. Closing Slack's window therefore gave three different switchers in three consecutive summons: the corpse re-discovered as an open window, then nothing at all for that app, then finally the closed-app icon.
.addWindowlessPlaceholder.