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 on screen and focused, so the weak signal flagged the FOREGROUND
window a phantom. Hidden but still holding MRU slot 0, it made the switcher's "previously-focused window"
default count one visible window too far and focus the wrong app. A window the user is looking at is never
a phantom, so PhantomWindowDetector.cgsVerdict now takes isFocused (front of the MRU AND app frontmost)
and exempts it from the weak signal only — after the strong signal, so #5714 stands.
Mirrors WindowEventReducerPhantomTests.swift 1:1.
.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. Focusing a window is proof it is real, so the verdict is now cleared at that moment
instead of waiting for a pass (TrackedWindowState.clearPhantomOnFocus).
C only covers focus that arrives as an 808. Two focus signals arrive as an AX kAXFocusedWindow READ
instead — the activation backstop (an activation that emits no 808) and the creation seed (a window
discovered while its app was already frontmost) — and both used to bump the MRU by calling
Windows.updateLastFocusOrder straight from the shell, bypassing the reducer's focus path and therefore the
clear in C. Reopening Slack from the Dock reaches the front through the activation, so the second #5849
report hit exactly that hole: the switcher summoned right after showed the app as a closed-app icon while
its hidden real window held slot 0, and the default pick landed on a third app. Both reads are now the
.axFocusedWindowRead input and share the one focus path; the gates they always had move with them.
kAXFocusedWindow answers "which window WOULD take keys",
which every app has at all times, so a background app's read fronts nothing (#5785).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.