Skip to content

fix(x): the hover companion stops eating the desktop around it - #883

Merged
arkml merged 1 commit into
mainfrom
fix/companion-click-through
Aug 21, 2026
Merged

fix(x): the hover companion stops eating the desktop around it#883
arkml merged 1 commit into
mainfrom
fix/companion-click-through

Conversation

@arkml

@arkml arkml commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The dead zone

The companion window is far bigger than anything it paints — a tall transparent stage sits above the card so popovers can open upward without resizing, and the tucked Skipper is just the mascot in that same frame.

But transparency is only paint. macOS routes a click to the topmost window by its rect, not by pixel alpha, and nothing in this window ever opted out — setIgnoreMouseEvents appeared exactly once in the repo, in screen-pointer.ts. So the stage swallowed every click that landed on it: a ~500px square of dead desktop around the companion. Folded, it was worse — the stage carried -webkit-app-region: drag, so pressing on what looked like empty desktop dragged the Skipper.

The fix

The window is created click-through and turns solid only while the cursor is over something actually drawn.

Main owns the cursor position. It polls screen.getCursorScreenPoint() every 40ms while the companion is pinned and pushes the point, in the window's own CSS pixels, over a new quick-ask:cursor channel. Mouse events are not a usable witness here: on macOS a -webkit-app-region: drag area is a native view layered over the page, so moves across it never reach the renderer — and the mascot is the drag handle. Driven by events alone it stayed click-through and could be neither clicked nor dragged.

The renderer answers the one question it alone can answer — is that point over paint? The hit test is inverted on purpose: only the frame's own containers carry data-qa-passthrough, so anything else under the cursor (including menus portaled to <body>, and anything added later) stays clickable by default. Wrong in that direction costs a dead pixel; wrong the other way costs an unclickable control.

The renderer is the single authority on the flag; main only overrides it when the pinned role is gone, which also tears down the hook so its cached verdict cannot drift. The mascot column is deliberately solid across its whole 132px footprint, so the Skipper is grabbable anywhere on it, exactly as before.

The fold flash

Folding the card no longer resizes the window. The frame is bottom-right anchored, so shrinking a 504px square to a 225px one moved its origin by 279px — and on a transparent window the OS frame change and Chromium's repaint of the newly-sized viewport are not atomic, so the mascot was composited against the wrong geometry for a frame or two, flashing well above where it lands. There is nothing left to shrink now that the space the card vacates is click-through, and the mascot's final position is unchanged either way.

The pill keeps its tuck resize — it folds to a genuinely different layout (the centered TuckedMascot), which only lands right in mascot-sized bounds.

Deliberate behavior changes

  • Click-just-above-the-card-to-tuck now fires within the grace ring rather than the full 80px band; past that the click belongs to whatever is behind. The chevron, the mascot pin, Esc and ⌥⇧Space are unaffected.
  • The folded stage is no longer a drag region. The mascot column is the handle in both states, as it already was.

Verification

npm run deps, npm run typecheck (shared/core/renderer), the main-process tsc --noEmit, and npm run lint are all clean.

Not yet exercised against a live voice session — worth confirming on the branch that the hat pins respond, that dragging works from anywhere on the mascot including the caption strip, and that clicks pass through the empty space around it.

🤖 Generated with Claude Code

The companion frame is far bigger than anything it paints: a tall
transparent stage sits above the card so popovers can open upward without
resizing, and the tucked Skipper is just the mascot in that same frame. But
transparency is only PAINT — macOS routes a click to the topmost window by
its RECT, not by pixel alpha — so that stage swallowed every click that
landed on it: a ~500px square of dead desktop around the companion, and an
invisible drag area that moved the Skipper when you pressed on what looked
like empty space.

The window is now created click-through and turns solid only while the
cursor is over something actually drawn. Main polls the cursor from the OS
and hands the point to the renderer, which hit-tests the DOM: mouse events
are not a witness we can use here, because on macOS a drag region is a
native view layered over the page, so moves across it never reach the
renderer at all — and the mascot IS the drag handle. Driven by events alone
it could be neither clicked nor dragged.

The hit test is inverted on purpose: only the frame's own containers are
marked passthrough, so anything else under the cursor — including menus
portaled to <body>, and anything added later — stays clickable by default.
Wrong in that direction costs a dead pixel; wrong the other way costs an
unclickable control. The mascot column is deliberately solid across its
whole 132px footprint, so the Skipper is still grabbable anywhere on it.

Folding the card no longer resizes the window either. The frame is
bottom-right anchored, so shrinking a 504px square to a 225px one moved its
origin by 279px, and on a transparent window the OS frame change and
Chromium's repaint of the newly-sized viewport are not atomic — the mascot
was composited against the wrong geometry for a frame or two, flashing well
above where it lands. There is nothing left to shrink now that the space the
card vacates is click-through. The pill keeps its tuck resize: it folds to a
genuinely different layout, which only lands right in mascot-sized bounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@arkml
arkml merged commit 5852004 into main Aug 21, 2026
3 checks passed
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.

1 participant