fix(desktop): gate the HUD resize frame's hit test on engagement (#108793)
The resize frame was unconditionally pointer-events: auto, so a collapsed HUD kept an invisible ring of window — nothing painted, every click and drag aimed at the app underneath eaten. The frame now rides the band's engagement gates: the caret in the composer (data-hud-typing, stamped live by useHudGlass), a held band, or a solid-input host re-arm it; an in-flight drag keeps its own handle live via data-hud-grabbing (pointer capture routes the moves). Direction from #108796 (liuhao1024); behavioral test instead of that PR's source-reading one. Co-authored-by: liuhao1024 <noreply@github.com>
This commit is contained in:
91
apps/desktop/src/app/hud/resize-frame.test.tsx
Normal file
91
apps/desktop/src/app/hud/resize-frame.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../contrib/wiring', () => ({ WiredPane: () => null }))
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
Object.assign(globalThis, { ResizeObserver: ResizeObserverStub })
|
||||
|
||||
import { HudShell } from './hud-shell'
|
||||
|
||||
/** #108793: the resize frame's hit-testability rides the shell's engagement
|
||||
* gates (styles.css keys `pointer-events` off them). The contract under test:
|
||||
* an idle HUD carries NONE of the gates, so the invisible frame falls out of
|
||||
* the hit test entirely; each arm — caret in the composer, a held band, a
|
||||
* solid-input host — re-arms it on its own. */
|
||||
describe('HudShell resize-frame engagement gates', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
const shellOf = (container: HTMLElement) => {
|
||||
const shell = container.querySelector('[data-hud-shell]')
|
||||
|
||||
expect(shell).not.toBeNull()
|
||||
|
||||
return shell as HTMLElement
|
||||
}
|
||||
|
||||
it('an idle HUD carries none of the engagement gates the frame keys on', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<HudShell />
|
||||
</MemoryRouter>
|
||||
)
|
||||
const shell = shellOf(container)
|
||||
|
||||
expect(shell.hasAttribute('data-hud-typing')).toBe(false)
|
||||
expect(shell.hasAttribute('data-hud-held')).toBe(false)
|
||||
expect(shell.getAttribute('data-hud-input')).toBe('click-through')
|
||||
// The frame itself is mounted (the resize grammar is not conditional) but
|
||||
// no handle is mid-drag.
|
||||
const handles = shell.querySelectorAll('[data-hud-resize]')
|
||||
|
||||
expect(handles.length).toBeGreaterThan(0)
|
||||
handles.forEach(handle => {
|
||||
expect(handle.hasAttribute('data-hud-grabbing')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('focus in the composer arms the typing gate the frame re-arms on', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<HudShell />
|
||||
</MemoryRouter>
|
||||
)
|
||||
const shell = shellOf(container)
|
||||
|
||||
// useHudGlass stamps data-hud-typing live from the :focus query — the
|
||||
// same gate the frost and the scrim run on.
|
||||
const composer = document.createElement('input')
|
||||
|
||||
composer.setAttribute('data-slot', 'composer-rich-input')
|
||||
act(() => {
|
||||
shell.appendChild(composer)
|
||||
composer.focus()
|
||||
composer.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(shell.hasAttribute('data-hud-typing')).toBe(true)
|
||||
})
|
||||
|
||||
it('a solid-input host keeps the frame live unconditionally', () => {
|
||||
Object.assign(window, { hermesDesktop: { hud: { windowing: { solid: true } } } })
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<HudShell />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(shellOf(container).getAttribute('data-hud-input')).toBe('solid')
|
||||
})
|
||||
})
|
||||
@@ -3578,14 +3578,33 @@ button[data-slot='aui_msg-reactions'] svg {
|
||||
|
||||
CanvasTTY grammar: four edge strips + four larger corner targets, each with
|
||||
its native resize cursor and each preserving the opposite edge. Deliberately
|
||||
invisible chrome: no glyph or border over whatever app is below the HUD. */
|
||||
invisible chrome: no glyph or border over whatever app is below the HUD.
|
||||
|
||||
Invisible chrome that is ALWAYS hit-testable is a dead zone: an idle HUD is
|
||||
just the bar, the window hands every other point to the app underneath, and
|
||||
the only thing left answering the cursor was this frame — a thin invisible
|
||||
ring that swallowed clicks and drags aimed at the app behind it (#108793).
|
||||
So the frame rides the band's engagement gates: while the HUD is being used
|
||||
(caret in the composer — data-hud-typing, stamped live by useHudGlass — or a
|
||||
held band waiting on an answer) its edges may be grabbed; an in-flight
|
||||
resize keeps its own handle live via data-hud-grabbing, because pointer
|
||||
capture routes the moves to the handle that started the drag. Solid-input
|
||||
hosts (X11) never hand the window away in the first place, so their frame
|
||||
stays live unconditionally. */
|
||||
[data-hud-shell] [data-hud-resize] {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
[data-hud-shell][data-hud-typing] [data-hud-resize],
|
||||
[data-hud-shell][data-hud-held] [data-hud-resize],
|
||||
[data-hud-shell][data-hud-input='solid'] [data-hud-resize],
|
||||
[data-hud-shell] [data-hud-resize][data-hud-grabbing] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-hud-shell] [data-hud-resize='n'] {
|
||||
top: 0;
|
||||
right: 1.25rem;
|
||||
|
||||
Reference in New Issue
Block a user