Fix the transient overlays: fade race, trail color, capture visibility - #70
Fix the transient overlays: fade race, trail color, capture visibility#70L-K-M wants to merge 6 commits into
Conversation
Five related defects in the short-lived overlays (the focus chip and the focus trail), all found by reading rather than on a device. 1. The chip could go permanently invisible. `generation` guarded the fade's completion handler but nothing cancelled the animation itself: NSWindow's animator proxy keeps stepping alphaValue toward its own target, so assigning `alphaValue = 1` in the next `show()` is simply overwritten on the animation's next step. A chip shown during its predecessor's 0.25s fade was therefore dragged back to zero, and the stale completion handler — correctly seeing a bumped generation — declined to order the window out. Result: a window ordered in at alpha 0 that never reveals again until a focus change lands outside a fade window. Precisely the run of quick app switches the chip exists to narrate. Both fades now run on a timer, the way PingWindow's already does, so the generation counter governs the animation and not just its tail. 2. `GhostBorderWindow` had the identical race, with a quieter symptom: a trail flashed during the previous trail's fade inherited that fade and showed dimmer or shorter. 3. The focus trail was painted in the *incoming* app's color. The ghost's HighlightView resolves its color at draw time via currentBorderColor(), which reads the frontmost app — and by the time the trail is flashed, focus has already moved. With per-app colors on, the ghost whose whole job is to say "you came from there" wore the color of where you went. HighlightView gains an optional overrideColor; FocusHighlighter captures the border color in showHighlight, and maybeShowFocusTrail — which runs before showHighlight — reads the previous capture, i.e. the outgoing window's color. 4. `disableFrameTimer` was the one timer in FocusHighlighter not added to the run loop in .common mode. With "Show border while dragging" off, a main run loop in a tracking mode (an open menu, a modal panel) when the re-enable was due stranded the border hidden until that loop exited. 5. "Show overlays in screenshots and recordings" didn't reach the trail or the chip — both pinned sharingType to .none — so opting in left a border-shaped hole where the trail was. Both now follow the switch, and forceUpdate fans a live toggle over every overlay including the ping. The trail is deliberately *not* cancelled from hideHighlight: app switches routinely pass through a transient "no focused window" refresh, and cutting it there would clip the animation the switch just started. Only the hard stand-down in suspendForTrustLoss() cancels it outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GquohA7BzXxdMSQ5kPp9Ru
GLM 5.2 Code ReviewActionable suggestions identified: 0 🟡 Minor comments (1)
ℹ️ Info comments (1)
|
Two review points from #70, both fair. The timer-driven fades ramped alpha linearly, where the NSAnimationContext path they replaced used its default ease-in-ease-out timing. The whole point of this PR is to fix the race without changing how the fade feels, so a smoothstep restores it — most noticeable on the ghost trail, whose 0.8 s duration gives a linear ramp room to read as mechanical. The reduce-motion branch of GhostBorderWindow.flash didn't reset alphaValue on completion, unlike the animated path and hide(). Harmless today, since nothing on that path lowers alpha, but leaving the window at a known 1 makes every exit consistent and can't be wrong later. Declined from the same review: adding a reduce-motion branch to FocusChipWindow's fade. The chip doesn't move — it appears and fades in place — and Reduce Motion asks for less *motion*, with cross-fades being the substitution Apple's own guidance recommends, not something to remove. The existing reduce-motion branches in GhostBorderWindow and PingWindow are there because those animations genuinely move: the ghost communicates direction, and the ping expands. Replacing the chip's fade with an abrupt disappearance would be worse for the same users, not better. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GquohA7BzXxdMSQ5kPp9Ru
Raised in review of #70, and fair: this PR introduced the duplication, so cleaning it up is its job. FocusChipWindow.startFade and GhostBorderWindow.flash had grown identical bodies — same 1/60 s interval, same Date()-based progress, same smoothstep, same completion sequence, with comments cross-referencing each other — which is a lockstep-maintenance hazard for anyone later tweaking the curve or the timing. `NSWindow.fadeOutAndOrderOut(over:while:)` joins `applyOverlaySharingType()` on the extension that already exists for exactly this kind of shared overlay behavior. The caller passes its duration and a generation predicate; the helper returns the timer so the caller can still invalidate it directly. The long explanation of *why* it isn't `animator().alphaValue` now lives in one place instead of two. Both call sites collapse to three lines. The completion no longer nils the caller's stored timer — nothing ever tested it for nil, and every path that starts a new fade invalidates the old reference first, which is a no-op on an already-invalidated timer. Declined from the same review: passing the outgoing border color to maybeShowFocusTrail as a parameter instead of reading `lastBorderColor`. The reviewer notes it's correct and documented today, and the suggestion doesn't actually remove the ordering dependency — refresh() would have to source the outgoing color from somewhere, and that somewhere is the same stored state. It would relocate the subtlety, not eliminate it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GquohA7BzXxdMSQ5kPp9Ru
Third review round on #70; one valid point. fadeOutAndOrderOut's doc comment promises the window is left at a known opacity, but that only held on natural completion — the cancelled branch invalidated the timer and returned with alpha wherever the fade had got to. Harmless today, because every caller resets alpha itself, but a helper whose contract states an invariant shouldn't depend on its callers to uphold it. Also recorded, in response to the review's "first-ever trail has a nil color" note: it can't. maybeShowFocusTrail is guarded on highlightVisible, and showHighlight sets lastBorderColor before setting highlightVisible — so by the time a trail can be shown at all, the captured color exists. Noting it in the comment so it isn't rediscovered as a suspected bootstrap bug. The round's other two items were self-answered by the reviewer (the smoothstep endpoints are correct; the chip's hold-timer guard is defense-in-depth because every generation bump invalidates the timer synchronously on the same thread) and need no change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GquohA7BzXxdMSQ5kPp9Ru
…ng note Fourth review round on #70. The comment claimed smoothstep "matches" NSAnimationContext's default ease-in-ease-out. It doesn't, quite: the default is the bezier (0.42, 0, 0.58, 1.0), which agrees with smoothstep at both endpoints and the midpoint but differs in steepness between. Imperceptible over a fade this short, but the comment overstated it — now it says "close enough" and names the actual curve. The review also suggested `lastBorderColor ?? HighlightView.currentBorderColor()` at the trail's call site, on the premise that the guard above only checks displayedBorderFrame/displayedCutout. That premise is wrong — the first guard requires highlightVisible, and showHighlight sets lastBorderColor before setting it, so the invariant does hold. More importantly the proposed fallback is the wrong direction: currentBorderColor() at that moment resolves the *incoming* app's hue, so the "safety net" would silently reinstate exactly the bug this path exists to fix, and do it in the one scenario nobody would be watching for. The underlying worry is fair though — it's a cross-function invariant, and its failure mode would be silent. So guard instead of assume: worst case the trail is skipped, which is invisible; it can never be drawn in the wrong colour. Also noted, no action: the fade's fixed 60 Hz cadence isn't vsync-locked on ProMotion. True, and true of every timer in the app — it's PERF-2 in ANALYSIS.md, deferred as a display-link rewrite, not something to solve one call site at a time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GquohA7BzXxdMSQ5kPp9Ru
|
Flagging one point from the latest round, because applying the suggestion as written would quietly undo what this PR fixes. The suggested guard focusChanged,
highlightVisible, // <-- here
dragTimer == nil,
...
More to the point, the fallback runs the wrong way. The robustness concern behind it is fair — it is a cross-function invariant with a silent failure mode — so I've taken it in the other direction and guarded rather than defaulted (74241ed). Worst case now the trail is skipped, which is invisible; it can never be drawn in the wrong colour. The easing-comment correction in the same round was right and is applied. Generated by Claude Code |
Good suggestion in review, and the right end state for the concern raised in the previous round. GhostBorderWindow.flash took NSColor? while its own documentation said the color *is* the outgoing window's — and nil doesn't mean "no color", it means "resolve at draw time", which by then yields the incoming app's hue. That is BUG-11 exactly, reachable by any future caller passing nil with no compiler complaint. Last round I guarded at the call site. A non-optional parameter is strictly better: the invariant moves from something one call site remembers to something the type system enforces. HighlightView.overrideColor stays optional, since nil there legitimately means "resolve the configured color normally" for the main overlay and the Settings preview. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GquohA7BzXxdMSQ5kPp9Ru
Five related defects in the short-lived overlays — the focus chip and the focus trail — found by reading, not on a device. Entries BUG-9 … BUG-13 in
opus.md.1. The focus chip could go permanently invisible
generationguarded the fade's completion handler, but nothing cancelled the animation.NSWindow's animator proxy keeps steppingalphaValuetoward its own target, so assigningalphaValue = 1in the nextshow()is simply overwritten on the animation's next step:show()bumpsgeneration, setsalphaValue = 1, orders frontorderOut. The window is now ordered in at alpha 0.The chip then stays invisible for its whole life, and for every subsequent focus change that lands inside a fade window. That's a run of quick app switches: exactly what the chip exists to narrate. It recovers on its own eventually, which makes it read as flaky rather than broken.
Both fades now run on a timer, the way
PingWindow's already does, so the generation counter governs the animation itself and not just its tail.2.
GhostBorderWindowhad the identical raceSame mechanism, quieter symptom: a trail flashed during the previous trail's fade inherited that fade and showed dimmer or shorter.
3. The focus trail was painted in the incoming app's color
The ghost's content view is a plain
HighlightView, which resolves its color at draw time fromcurrentBorderColor()— which readsNSWorkspace.shared.frontmostApplication. By the time the trail is flashed, focus has already moved. So with per-app colors on, the ghost whose entire job is to say "you came from over there" wore the color of where you just went — the one piece of information it must not carry.HighlightViewgains an optionaloverrideColor;FocusHighlightercaptures the border color at the top ofshowHighlight, andmaybeShowFocusTrail— which runs beforeshowHighlight— reads the previous capture, i.e. the outgoing window's color. No new "previous app" bookkeeping needed; the existing call order does the work.4.
disableFrameTimerwasn't in.commonrun-loop modeThe one timer in
FocusHighlighternot added to.common. With "Show border while dragging" off, a main run loop sitting in a tracking mode (an open menu, a modal panel) when the re-enable was due stranded the border hidden until that loop exited.5. "Show overlays in screenshots" didn't reach the trail or the chip
Both pinned
sharingTypeto.none, andforceUpdate()fannedapplyOverlaySharingType()over only the border and the dim windows. A user who opted in to record a demo of the app got the border and a border-shaped hole where the trail was. Both now follow the switch, and the live fan-out covers every overlay including the ping.Deliberately not done
The trail is not cancelled from
hideHighlight(), though the chip is. App switches routinely pass through a transient "no focused window" refresh (the reasondisplayedCutoutis kept across hides), and cutting the trail on one of those would clip the animation the switch just started. It fades out on its own inside a second; only the hard stand-down insuspendForTrustLoss()cancels it outright.Testing
Not compiled — authored without Xcode; CI builds this. Each change is local and reversible: the two fade rewrites follow the pattern
PingWindowalready proves,overrideColordefaults tonil(unchanged behavior for both other callers), and the run-loop mode and sharing-type changes make two outliers match the file's existing convention.Generated by Claude Code