diff --git a/apps/desktop/src/app/hud/resize-frame.test.tsx b/apps/desktop/src/app/hud/resize-frame.test.tsx new file mode 100644 index 0000000000..735a5db73e --- /dev/null +++ b/apps/desktop/src/app/hud/resize-frame.test.tsx @@ -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( + + + + ) + 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( + + + + ) + 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( + + + + ) + + expect(shellOf(container).getAttribute('data-hud-input')).toBe('solid') + }) +}) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index b492e4b93a..2cef8bd884 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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;