Hover intent: tooltips and hover cards wait, then stay warm, app-wide

Merged
TE
22d ago
+2600-278

Summary

Tooltips and hover cards used to show at once on every control or row the pointer passed, so a sweep across a toolbar or down a list left a trail of them. They now follow one hover-intent policy, the same in the app and in its pages, modeled on AppKit. The tooltip itself is now frosted glass.

Tooltips

  • The first tooltip waits 450 ms.
  • While one is showing, moving to a neighbor shows the neighbor's tooltip at once (the group is warm).
  • Warmth cools 500 ms after the last tooltip hides.
  • A click, a key or a scroll hides the tooltip at once and cools the group, as AppKit does.
  • Motion:
    • The first tooltip of a group fades in over the fadeIn token (120 ms) and rises 2 pt.
    • A warm neighbor's tooltip appears at once, with no second fade.
    • Leaving fades it out faster, over the fadeOut token (80 ms).
    • Reduce Motion keeps the fade and drops the rise.
  • Surface (one token, native and pages, TooltipSurface):
    • Frosted glass: NSVisualEffectView behind the window natively, and backdrop-filter: blur(24px) in pages.
    • The tint is the popup surface at the lowest alpha that keeps the primary text at 4.5:1 or better over pure black and over pure white. The blur can only average what's underneath, so those two are the worst case.
    • The rim is one device pixel (--cmux-hairline from #20481).
    • The shadow is elevation 1 from #20519: --elevation-1 in pages and PopupStyle.elevation1 natively (0 1px 2px, black at 7%, 12% in dark). Base had given the title tooltip --elevation-2; a tooltip is the smallest surface, so it takes step 1.
    • Reduce Transparency draws the opaque popup surface instead.
  • Labeled controls get no tooltip. A control whose visible label already says the tooltip text shows none (#20375's rule, applied at runtime). Natively this covers a titled button or a text field. In pages it covers an element whose own text matches.

Sidebar workspace cards

  • They keep the 1 s dwell, and an open card still slides from row to row.
  • A pointer heading into the card no longer hands the card to a row it crosses on the way. A move counts as heading into the card when it lands inside the safe triangle and its step toward the card is at least half its step along it.
  • A pointer resting on the card keeps it open.
  • Leaving the card starts a 200 ms grace before it closes.

Row highlights in pages appear on the frame the pointer reaches the row, with no fade-in, so a fast sweep no longer leaves half-faded tints. The fade on leaving stays, and native sidebar rows keep their cross-fade from #20315.

Why these numbers

AppKit's tooltip waits NSInitialToolTipDelay, about 1 s at the default setting, and after that the next view's tooltip shows at once. Leo asked for 400–500 ms, and 450 ms sits comfortably on both sides of the trade:

  • A sweep still shows nothing. A pointer crossing a 24–30 pt toolbar button at sweep speed (300–1000 pt/s) is on it for 30–100 ms, far under the delay.
  • A deliberate rest gets its answer in under half a second.

The other values:

  • The 500 ms warm time is about the gap a reader leaves between two buttons.
  • The card dwell stays at the existing 1 s.
  • The 200 ms grace and the 300 ms aim window are in the usual menu-aim range.
ConstantValueNative tunableWeb
Tooltip delay450 msmotion.tooltip.delayHOVER_INTENT.tooltipDelay
Tooltip warm (cool-down)500 msmotion.tooltip.warmHOVER_INTENT.tooltipWarm
Card dwell1000 msmotion.hoverCard.dwell (existing)HOVER_INTENT.cardDwell
Card grace on leave200 msmotion.hoverCard.graceHOVER_INTENT.cardGrace
Card aim (pause allowed while heading in)300 msmotion.hoverCard.aimHOVER_INTENT.cardAim
Fade in / out120 / 80 msMotionFade.fadeIn / .fadeOut--ui-popup-open / --ui-popup-close
Tooltip blur24 pxTooltipSurface.blurRadius--tooltip-blur
Least tint alpha86%TooltipSurface.minimumTintAlpha--tooltip-tint fallback
Shadowelevation 1 (0 1px 2px, 7%)TooltipSurface.shadow = PopupStyle.elevation1--tooltip-shadow: var(--elevation-1)

Every value is a tunable or a design token, not a user setting.

  • The timing lives in Swift (HoverIntentTiming.standard) and in the web constant HOVER_INTENT. webviews/test/hover-intent-parity.test.ts fails when the two drift.
  • webviews/test/tooltip-surface.test.ts does the same for the surface constants.
  • Pages get the per-theme tint from the host as --cmux-tooltip-tint (WebTheme).

The primitive

Native, CmuxNextDesign/HoverIntent/HoverIntent.swift:

  • HoverIntentTiming reads the timing through the tunables.
  • HoverIntent<Target> is a pure reducer that owns the delay and the warm group. Its owner runs the effects on one DemandTimer, and the clock is injectable.
  • CGRect.isAimedAt(from:to:) is the safe triangle plus the heading test.
  • TooltipPresenter runs on HoverIntent.
  • HoverCardMachine and HoverCardCoordinator use the timing and the aim test.
  • TooltipPanel draws TooltipSurface and runs the fades through Motion.set, so check-motion stays green.

Web, webviews/src/ui/hoverIntent.ts:

  • HoverIntent, plus HoverGroup for the warm group. Pages share one tooltip group, and cooling the group also drops other intents' pending waits.
  • The useHoverIntent hook.
  • aimingAt, the safe triangle.
  • An injectable HoverClock.
  • The surface and fades are CSS in pages/shared/desktop.css (--tooltip-*, data-starting-style, data-ending-style, and data-instant for a warm neighbor).

Timers moved onto it

WhereBeforeNow
TooltipPresenter (every native themedToolTip / .themedHelp)instantHoverIntent: 450 ms, then warm
WorkspaceHoverCardController.delaymotion.hoverCard.dwell read inlineHoverIntentTiming.current.cardDwell
HoverCardMachine.leaveWindow150 ms literalcardGrace (200 ms), plus aimWindow and a hold while the pointer is on the card
ui/Tooltip TooltipProvider (Base UI)delay={0}delay=450, timeout=500 (Base UI's warm window)
ui/titleTooltips.ts (every title in pages)next frameHoverIntent on the shared tooltip group
ui/Toolbar.tsx hintits own 500 ms / 400 ms groupuseHoverIntent on the shared group; keyboard focus shows it at once
Agent pane thread minimap turn preview110 ms POPOVER_DELAY_MSuseHoverIntent (450 ms, then follows ticks at once)
Markdown link hover card250 ms setTimeoutHoverIntent for the pointer; the caret's card keeps 250 ms
Agent pane submenu safe triangle (acpmux/useHoverIntent.ts)its own geometryaimingAt from ui/hoverIntent.ts; its 150 ms submenu delay is unchanged

Left as they are:

  • Tab-strip hover cards keep their own Chrome-style TabPreviewPolicy delay. They already run through HoverCardMachine, so they get the grace and the aim test (with the heading check, so sliding along the strip still hands the card over at once).
  • The Settings background-tile preview keeps its 150 ms dwell. It's a live preview, not a hover card.
  • NSMenuItem.toolTip stays AppKit's own.
  • The group editor bubble (its ⋯ and right-click openers and its show/dismiss path) and group-chip hover are untouched, at Lawrence's chief's request.
  • No layout changes. The sidebar is being redone separately.
  • Base UI tooltips (<Tooltip>) keep Base UI's own warm window, which has the same 450/500 ms timing, separate from the title-tooltip group.

A tooltip far from the pointer no longer wins

While recording I found that the toolbar's first tooltip took about 1 s on base, and a warm neighbor's tooltip appeared and then vanished. The cause was the sidebar's bottom "Default" row: AppKit reported its visibleRect as {{-8,-692},{208,720}}, past its 34 × 24 bounds, so its .inVisibleRect tracking area covered the whole sidebar. As the deepest tracker it beat the toggle and New Workspace, and its tooltip was placed off the window until the 500 ms recheck picked the real one. TooltipPresenter now only lets trackers whose view is under the pointer compete (and falls back to all of them if none is). Base has the same bug, which the before clips show.

Overlap with #20375

#20375 (open) also edits webviews/src/ui/titleTooltips.ts and Tooltip.tsx (TOOLTIP_DELAY 1000, TOOLTIP_WARM 400) and adds test/tooltip-rules.ts. Whichever lands second resolves the conflict. This PR's timing is Leo's 450 ms, from HOVER_INTENT.

Testing

Red and green ran the same commands. Each red commit comes before its fix in the branch.

Native (fleet: cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg Packages/macOS/CmuxNext)

Red, first round, at 76ccbaeee84d (aws-m4pro-9):

  • TooltipHoverIntentTests had 6 issues. A sweep showed ["Back", "Forward", "Reload", "Share", "Downloads"], and the first tooltip showed with no delay.
  • SidebarHoverCardDwellTests failed: the diagonal path into the card slid it to ws-2, and the card closed while the pointer rested on it.

Red, second round, at bdd850384de2 (aws-m4pro-9):

  • TooltipHoverIntentTests failed because the tooltip waited 800 ms instead of 450 ms.
  • TooltipSurfaceTests failed: no --cmux-tooltip-tint, and no tooltip after 500 ms.
  • ThemedTooltipTests failed: the fill was opaque instead of the frosted tint.

Red for the strengthened review tests, at 13410b2baca7 (a not-for-merge branch: the red commit plus the two tests), on aws-m4pro-2:

  • movingDownTheListWithDriftSlidesTheCardAtOnce failed: the card stayed on row 0.
  • aLabeledControlInsideATooltipViewShowsNeither failed: it showed the container's "Page actions".

Red for the Default-row bug, at 6a3c382d94b0 (the red test on its own) on aws-m4pro-3: aDeeperTooltipAwayFromThePointerDoesNotWin failed with (presenter.shown?.text → "Default") == "Back".

Green:

  • At 081bbaf on aws-m4pro-7, a display host, so no motion skips: every suite passed, including TooltipSurface, TooltipHoverIntent, ThemedTooltip, SubtleElevation, SidebarHoverCardDwell and SpareNewTabChatOpensTabs.
  • At the head 33444f2de1fe on aws-m4pro-2: 306 of 306 suites passed, including aDeeperTooltipAwayFromThePointerDoesNotWin.
    • An earlier round there on aws-m4pro-3 timed out once each in BranchStateResourcesTests and PinnedPageTests (waiting on a pinned tab). Both passed in every other round, and this PR doesn't touch them.
  • Every round exits 1 only on the fleet's same-tree guard (AcpmuxBundleReplacedTests skipped for want of a published cmux-tui tree). That's infra.
  • This PR also fixes a base compile red in SpareNewTabChatOpensTabsTests, which passed tag: to AgentTabStore(build:).

Web (heavy --mem 4G bun test; browser tests use CMUX_BROWSER_TESTS=1 heavy --mem 6G, Chromium)

Red at ae4b3073af6e:

  • src/ui/titleTooltips.test.ts failed 3 cases.
  • test/hover-intent-cards.test.tsx failed 1 (the minimap preview showed after 110 ms).
  • test/hover-row-sweep.test.ts failed 2: a sweep sampled 27 half-faded fills.

Red at bdd850384de2:

  • titleTooltips.test.ts failed 2: the 450 ms delay, and a key not dropping a waiting toolbar hint.
  • test/tooltip-surface.test.ts failed 4:
    • no 120 ms fade or 2 px rise;
    • no fade under Reduce Motion;
    • a solid rgb(52, 49, 68) fill instead of the tint, with no blur;
    • the constants differed from the Swift ones.

Red at aaa5d137f8ea: tooltip-surface.test.ts showed a click leaving the tooltip fading ({hidden: false, ending: true}).

Green:

  • PR CI web / react-apps-check at 33444f2de1fe ran 270 test files. Every file this PR adds or changes passes: titleTooltips, hover-intent-parity, hover-intent-cards, hover-row-sweep, tooltip-surface, popup-surface, subtle-elevation, chat-header-tooltips and ui-a11y. The one red is theme-colors.test.ts, on agent-session/acpmux/composerStates.css:28, a line that came from #20561 on base and isn't in this branch.
  • After merging base (#20519), the focused runs passed: subtle-elevation.test.ts and tooltip-surface.test.ts (6/6, Chromium).

Final head 6248e28573c0: the tip a5f878e69fc7 merged in, with ci-target-graph.json restored to the tip's (the merge had dropped CmuxNextAgentLanes).

  • The release compile passed on it (cmux-ci isolated, check-release-compile.sh).
  • Run again on it, subtle-elevation, popup-surface, hover-intent-parity and titleTooltips passed 50/50.
  • react-apps-check there stopped at lint, on seven control-has-associated-label warnings in base's home-channels/ChiefSheet.tsx and ConversationSheets.tsx.

Guards: crash_ratchet.py, check-concurrency.sh, check-system-blue and check-motion all pass.

Clips

Each clip is before (left, feat-cmux-next) and after (right, this PR), both recorded the same way on a capture mini at about 60 fps with the art wallpaper on. Native hovers are driven with debug.pointer_hover. Pages are hovered with a new debug-only debug.agent_pane {action:"hover"}, which fires the page's pointer events, because an inactive capture-slot app gets no real mouse moves. Base was built from feat-cmux-next plus only the three debug commits. Times are from frame onsets.

Fast sweep: sidebar rows, then title-bar and pane-toolbar buttons at sweep speed. Before, the pane toolbar leaves a trail of tooltips (Changes, Terminal, Browser); after, nothing shows.

Fast sweep

Slow dwell, native: rest on the sidebar toggle, slide to New Workspace, leave, rest again. After: the first tooltip shows 0.50 s after the rest, New Workspace shows at once (warm), and after cooling the next rest waits again (3.27 to 3.75 s). Before: the first takes about 0.85 s, and New Workspace's tooltip shows then vanishes at the recheck (the Default-row bug above).

Slow dwell, native

Slow dwell, page toolbar: over the art wallpaper. Before: instant (0.33 to 0.35 s). After: 0.31 to 0.83 s, then neighbors at once.

Slow dwell, page toolbar

Sidebar cards: rest on a workspace row (1 s card), then slide down. The card follows row to row once open, in both.

Sidebar cards

Jump to latest, busy wallpaper: after a fling up a real chat. Before: instant. After: 0.50 s, on the frosted glass surface, then fades out over 80 ms.

Jump to latest, busy wallpaper

Jump to latest, light theme (Rose Pine Dawn): the same over a light page; the tint keeps the label readable.

Jump to latest, light theme (Rose Pine Dawn)

Page toolbar, light theme: the page dwell again under Rose Pine Dawn.

Page toolbar, light theme

Changelog

Changed: Tooltips and hover cards wait briefly before showing, then show instantly for neighbors, like macOS.

🤖 Generated with Claude Code