Skip to content

Fix the transient overlays: fade race, trail color, capture visibility - #70

Open
L-K-M wants to merge 6 commits into
mainfrom
claude/opus-transient-overlays
Open

Fix the transient overlays: fade race, trail color, capture visibility#70
L-K-M wants to merge 6 commits into
mainfrom
claude/opus-transient-overlays

Conversation

@L-K-M

@L-K-M L-K-M commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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

generation guarded the fade's completion handler, but nothing cancelled the animation. 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:

t what happens
0 chip A shows; hold timer armed for 0.8 s
0.8 hold fires; a 0.25 s fade to 0 starts
0.9 focus changes again — show() bumps generation, sets alphaValue = 1, orders front
0.9–1.05 A's fade is still running and drags the new chip's alpha to 0
1.05 A's completion handler sees a stale generation and — doing its job — declines to orderOut. 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. GhostBorderWindow had the identical race

Same 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 from currentBorderColor() — which reads NSWorkspace.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.

HighlightView gains an optional overrideColor; FocusHighlighter captures the border color at the top of showHighlight, and maybeShowFocusTrail — which runs before showHighlight — 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. disableFrameTimer wasn't in .common run-loop mode

The one timer in FocusHighlighter not 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 sharingType to .none, and forceUpdate() fanned applyOverlaySharingType() 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 reason displayedCutout is 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 in suspendForTrustLoss() 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 PingWindow already proves, overrideColor defaults to nil (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

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
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

GLM 5.2 Code Review

Actionable suggestions identified: 0

🟡 Minor comments (1)

HighlightWindow.swift - Timer tolerance for fade animation
Problem: The fade timer in fadeOutAndOrderOut fires at a fixed 1/60s interval with no tolerance, which prevents macOS from coalescing it with other system timers.
Impact: On battery-powered Macs, every tight repeating timer keeps the CPU from entering low-power states. The visual impact of allowing a small tolerance is imperceptible over a 0.25-second fade, but the energy benefit compounds across frequent focus changes.
Suggested fix:

- RunLoop.current.add(timer, forMode: .common)
+ timer.tolerance = 1.0 / 120.0
+ RunLoop.current.add(timer, forMode: .common)

Prompt for AI Agents:

In the `fadeOutAndOrderOut` method in HighlightWindow.swift, add `timer.tolerance = 1.0 / 120.0` before the `RunLoop.current.add(timer, forMode: .common)` line. This allows the system to coalesce the 60fps fade timer with other timers for better energy efficiency without perceptible visual change.
ℹ️ Info comments (1)

HighlightWindow.swift - fadeTimer retains invalidated timer after natural completion
Problem: When fadeOutAndOrderOut completes naturally (the timer fires with t >= 1), it invalidates the timer internally but the caller's fadeTimer property still holds a reference to the now-invalidated timer. The reduce-motion path in GhostBorderWindow.flash() nils out fadeTimer on completion, but the fade path does not.
Impact: No functional bug — the invalidated timer is a no-op on subsequent invalidate() calls, and it gets replaced on the next show()/flash()/hide(). The only cost is the timer object living slightly longer than necessary. Noting this purely for consistency awareness; no action required unless you want the two exit paths to mirror each other.
Suggested fix:
No code change recommended — the inconsistency is harmless and adding a completion callback to fadeOutAndOrderOut would complicate its API for negligible gain.
Prompt for AI Agents:

No action needed. This is an informational note about a harmless inconsistency between the reduce-motion timer path (which nils out fadeTimer on completion) and the fade path (which does not). Verify this understanding is correct and move on.

claude added 2 commits July 25, 2026 20:51
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
claude added 2 commits July 25, 2026 20:58
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

L-K-M commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Flagging one point from the latest round, because applying the suggestion as written would quietly undo what this PR fixes.

The suggested lastBorderColor ?? HighlightView.currentBorderColor() rests on "the visible guard actually only checks displayedBorderFrame/displayedCutout". There are two guards in maybeShowFocusTrail, and the first one — above the diff hunk — requires highlightVisible:

guard focusChanged,
      highlightVisible,           // <-- here
      dragTimer == nil,
      ...

highlightVisible is assigned in exactly two places: true at the end of showHighlight, false in hideHighlight. showHighlight captures lastBorderColor at its top, before that assignment. So highlightVisible == true does imply a captured colour, and the invariant holds.

More to the point, the fallback runs the wrong way. currentBorderColor() evaluated at that moment reads NSWorkspace.shared.frontmostApplication, which by then is the app focus just moved to — so with per-app colours on, the "safety net" would paint the outgoing window's ghost in the incoming app's hue. That's BUG-11, the bug this PR exists to fix, reinstated silently and only in the path nobody is watching.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants