fix(x): the hover companion stops eating the desktop around it - #883
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 —
setIgnoreMouseEventsappeared exactly once in the repo, inscreen-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 newquick-ask:cursorchannel. Mouse events are not a usable witness here: on macOS a-webkit-app-region: dragarea 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
Verification
npm run deps,npm run typecheck(shared/core/renderer), the main-processtsc --noEmit, andnpm run lintare 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