diff --git a/apps/desktop/src/components/ui/tooltip.test.tsx b/apps/desktop/src/components/ui/tooltip.test.tsx
new file mode 100644
index 00000000000..06087a69623
--- /dev/null
+++ b/apps/desktop/src/components/ui/tooltip.test.tsx
@@ -0,0 +1,56 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { Tip } from './tooltip'
+
+describe('Tip', () => {
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('shows on pointer enter and dismisses on pointer leave', async () => {
+ render(
+
+
+
+ )
+
+ const trigger = screen.getByRole('button', { name: 'layout' })
+
+ fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
+ expect((await screen.findByRole('tooltip')).textContent).toContain(
+ 'Layout editor — ⌘-click resets the layout'
+ )
+
+ fireEvent.pointerLeave(trigger)
+ await waitFor(() => {
+ expect(screen.queryByRole('tooltip')).toBeNull()
+ })
+ })
+
+ it('never captures pointer events on the tip surface', async () => {
+ render(
+
+
+
+ )
+
+ fireEvent.pointerMove(screen.getByRole('button', { name: 'target' }), { pointerType: 'mouse' })
+ const tip = await screen.findByRole('tooltip')
+ // Role lives on the visually-hidden a11y node; the portaled content root
+ // is the data-slot wrapper that must stay click-through.
+ const content = tip.closest('[data-slot="tooltip-content"]') ?? tip.parentElement
+ expect(content?.className).toMatch(/pointer-events-none/)
+ })
+
+ it('renders the child alone when label is empty', () => {
+ render(
+
+
+
+ )
+
+ expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy()
+ expect(screen.queryByRole('tooltip')).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx
index 6b596b0ce77..d1053585869 100644
--- a/apps/desktop/src/components/ui/tooltip.tsx
+++ b/apps/desktop/src/components/ui/tooltip.tsx
@@ -3,8 +3,23 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
-function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) {
- return
+function TooltipProvider({
+ delayDuration = 0,
+ // Tips are labels, not interactive surfaces. Hoverable content + Radix's
+ // pointer-grace bridge is what leaves tips stuck open — especially over
+ // Electron `-webkit-app-region: drag` chrome where pointermove never fires
+ // to clear the grace area. Default off so open state tracks the trigger only.
+ disableHoverableContent = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
}
function Tooltip({ ...props }: React.ComponentProps) {
@@ -28,7 +43,9 @@ function TooltipContent({
// span so `box-decoration-break: clone` gives a marker-style background
// that hugs EACH wrapped line (bg only on the text, ragged right — no
// rectangular dead space). Instant, no transition (delayDuration=0).
- className={cn('z-[200] w-fit max-w-64 select-none', className)}
+ // pointer-events-none: the tip must never steal hover/clicks from the
+ // chrome underneath (titlebar tools, adjacent tabs, etc.).
+ className={cn('pointer-events-none z-[200] w-fit max-w-64 select-none', className)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}
@@ -53,15 +70,15 @@ interface TipProps extends Omit{children}>
}
return (
-
-
+
+
{children}
{label}