src/switcher/state/PhantomWindowDetectorSpecs.md
PhantomWindowDetector decides whether a window is a phantom — present in macOS APIs (AX returns it
as a live window with a valid CGWindowID) but not something the app means to show the user, so AltTab
shouldn't offer it as a switch target. The pixel content may be absent, black, or anything; that's the
symptom, not the definition. Producers: alpha=0 Outlook reminders (#5170/#5448), orderOut: /
show:false Electron windows (Codex/Slack #5714, Joplin #5495, Sprig #5496), WeChat/Teams/DingTalk
hidden windows (#5508). Extracted as a pure kernel from Window / Applications so the "is this a
phantom?" decision is unit-testable without CGS/AX. Full investigation: PhantomWindowDetection.swift.
A phantom is read on two orthogonal CGS axes:
cgWindowId.spaces() → CGSCopySpacesForWindows(…, .all, …)) — which Space a
window belongs to. orderOut: / setAlphaValue:0 does not un-assign it.CGSCopyWindowsWithOptionsAndTags with vs. without the .invisible1/.invisible2
bits) — the inVisibleList (excludes the invisible tags) vs inAllList (includes them) pair.They're independent, which gives two strengths of phantom:
spaceIds comes back [].inAllList but not inVisibleList; CGS still tracks it (so spaceIds is
non-empty) but tags it invisible. Non-empty spaceIds therefore does not imply visibility.syncVerdict(s, app) — synchronous, cheap, runs on every show (Window.recomputeIsPhantom). Has
only local facts, so it can observe only the strong signal. Monotonic for the weak signal: it ORs
the strong signal onto the current s.isPhantom, so it may raise the flag but never clears it on a
non-empty Space. A weak-signal phantom keeps its Space, which this path can't see; clearing there would
clobber cgsVerdict's verdict on every show and the phantom would reappear on every summon (the #5714
bug). Exception — isTabbed clears: AX tab detection is authoritative but lands after a window is
first seen, so an inactive tab is briefly flagged phantom (empty spaceIds, not-yet-known tabbed); once
AX confirms the tab this path un-flags it (a real phantom is never part of an AXTabGroup). Without it,
the monotonic OR left inactive tabs stuck phantom and "separate window per tab" showed one per app.cgsVerdict(s, app, inVisibleList, inAllList, visibleSpaceIds) — authoritative, runs ~250ms
post-show off-main (Applications.refreshIsPhantom) with the two CGS lists. Knows both signals; owns
the full verdict, including clearing. Disambiguation order (first match wins):
inAllList → phantom (strong)inVisibleList → not a phantom (currently rendered)spaceIds ∩ visibleSpaceIds == ∅ → not a phantom (other-Space window)orderOut: on a visible Space)Mirrors PhantomWindowDetectorTests.swift 1:1. Each test starts from an all-permissive baseline window
and flips the knobs it exercises.
spaceIds → stays a phantom (the #5714
invariant: the synchronous path never clears an authoritative verdict).spaceIds are sibling-backfilled) → not a phantom.
Regression for the fullscreen-tab / "separate window per tab" disappearance — the legitimate-window
exemption must beat the strong signal.isFocused means the window is at the front of the MRU AND its app is frontmost, i.e. the window the user
is looking at right now. Electron apps reopened from the Dock (Slack, Telegram) keep their window tagged
invisible by CGS for seconds after it is on screen and focused, so the weak signal flagged the FOREGROUND
window a phantom. Two things then went wrong at once: the app looked windowless, so a placeholder tile was
spawned, and the hidden window still held its MRU slot, so the switcher's "previously-focused window"
default counted one visible window too far and landed on the wrong app.
isFocused → not a phantom.