diff --git a/docs/systems/design-system.md b/docs/systems/design-system.md index 96cbeae4..8a46a093 100644 --- a/docs/systems/design-system.md +++ b/docs/systems/design-system.md @@ -63,6 +63,8 @@ Font: DM Sans (primary) with system fallbacks **Modal backdrops:** `bg-black/50` — light enough for glass blur to show through. +**Portal target — `usePortalContainer()`:** Every overlay (context menu, tooltip, popover, modal, screen-share picker) MUST portal through `usePortalContainer()` (`packages/web/src/hooks/usePortalContainer.ts`) instead of hard-coding `document.body`. The hook returns `document.fullscreenElement ?? document.body` and re-renders subscribers on `fullscreenchange`. Without this, anything portaled while an element (e.g. the voice container in fullscreen mode) is in the browser's Fullscreen API top-layer is rendered outside that layer and is invisible. Components mounted at App root that render with `fixed inset-0` (not just portals) must also portal through this hook for the same reason. + ### Glass Material Properties ```css .glass { diff --git a/docs/systems/voice.md b/docs/systems/voice.md index a0991c28..28b6d647 100644 --- a/docs/systems/voice.md +++ b/docs/systems/voice.md @@ -236,6 +236,14 @@ ScreenShareConfig { --- +## Voice Fullscreen + +The fullscreen toggle in `VoiceControlBar` flips the `voiceFullscreen` flag in `uiStore`; an effect in `MainContent.tsx` calls `voiceContainerRef.current.requestFullscreen()` (and exits via `document.exitFullscreen()` when the flag clears). A second effect listens to `fullscreenchange` and reflects the actual `document.fullscreenElement` back into the store, so pressing Esc or system-level fullscreen-exit keeps state in sync. `voiceChatOpen && !voiceFullscreen` hides the side chat panel while fullscreen is active. + +**Overlay portals:** While fullscreen is active the browser's Fullscreen API renders only descendants of `voiceContainerRef`. Every overlay reachable during a call (context menus on `StreamTile`/`VoiceUser`/`VoiceChannel`, tooltips on the control bar, `ConnectionInfoPopover`, `ScreenShareSettingsPopover`, `ConfirmDialog` invoked from voice context-menu actions, and `ScreenSharePicker`) portals through `usePortalContainer()` so it lands inside the fullscreen element. Adding new overlays that can be opened from inside the call must follow the same contract — see `docs/systems/design-system.md` Surface Material Tiers. + +--- + ## Audio Processing | Feature | Default | User Control | Notes | diff --git a/packages/web/src/components/ui/ConfirmDialog.tsx b/packages/web/src/components/ui/ConfirmDialog.tsx index 6d141a9f..4d6f149b 100644 --- a/packages/web/src/components/ui/ConfirmDialog.tsx +++ b/packages/web/src/components/ui/ConfirmDialog.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useCallback } from 'react'; import ReactDOM from 'react-dom'; +import { usePortalContainer } from '../../hooks/usePortalContainer'; interface ConfirmDialogProps { isOpen: boolean; @@ -38,6 +39,8 @@ export function ConfirmDialog({ } }, [isOpen, handleKeyDown]); + const portalContainer = usePortalContainer(); + if (!isOpen) return null; const isDanger = variant === 'danger'; @@ -74,6 +77,6 @@ export function ConfirmDialog({ , - document.body, + portalContainer, ); } diff --git a/packages/web/src/components/ui/ContextMenuRenderer.tsx b/packages/web/src/components/ui/ContextMenuRenderer.tsx index 7dae29c7..aa61d8ee 100644 --- a/packages/web/src/components/ui/ContextMenuRenderer.tsx +++ b/packages/web/src/components/ui/ContextMenuRenderer.tsx @@ -9,6 +9,7 @@ import { type ContextMenuSubmenu, } from '../../stores/contextMenuStore'; import { useUIStore } from '../../stores/uiStore'; +import { usePortalContainer } from '../../hooks/usePortalContainer'; // ── Desktop item button ────────────────────────────────────────────────────── @@ -83,6 +84,7 @@ interface SubmenuFlyoutProps { function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close }: SubmenuFlyoutProps) { const flyoutRef = useRef(null); + const portalContainer = usePortalContainer(); const filteredChildren = filterMenuItems(submenu.children); useLayoutEffect(() => { @@ -125,7 +127,7 @@ function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close ))} , - document.body, + portalContainer, ); } @@ -281,6 +283,7 @@ interface DesktopMenuProps { function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) { const menuRef = useRef(null); + const portalContainer = usePortalContainer(); // Viewport-aware positioning via direct DOM mutation useLayoutEffect(() => { @@ -411,7 +414,7 @@ function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) { ))} , - document.body, + portalContainer, ); } @@ -463,6 +466,7 @@ interface MobileMenuProps { function MobileMenu({ items, close }: MobileMenuProps) { const [submenuStack, setSubmenuStack] = useState(null); + const portalContainer = usePortalContainer(); // Dismiss on scroll/resize useEffect(() => { @@ -527,7 +531,7 @@ function MobileMenu({ items, close }: MobileMenuProps) { , - document.body, + portalContainer, ); } diff --git a/packages/web/src/components/ui/Tooltip.tsx b/packages/web/src/components/ui/Tooltip.tsx index fb79fb19..7eac2773 100644 --- a/packages/web/src/components/ui/Tooltip.tsx +++ b/packages/web/src/components/ui/Tooltip.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; +import { usePortalContainer } from '../../hooks/usePortalContainer'; import { useUIStore } from '../../stores/uiStore'; interface TooltipProps { @@ -19,6 +20,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }: const timeoutRef = useRef>(); const anchorRef = useRef(null); const floatingRef = useRef(null); + const portalContainer = usePortalContainer(); const { style } = useFloatingPosition(anchorRef, floatingRef, { placement: position, @@ -52,7 +54,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }: > {content} , - document.body, + portalContainer, )} ); diff --git a/packages/web/src/components/voice/ConnectionInfoPopover.tsx b/packages/web/src/components/voice/ConnectionInfoPopover.tsx index a7134026..5036f70e 100644 --- a/packages/web/src/components/voice/ConnectionInfoPopover.tsx +++ b/packages/web/src/components/voice/ConnectionInfoPopover.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; +import { usePortalContainer } from '../../hooks/usePortalContainer'; interface ConnectionInfoPopoverProps { open: boolean; @@ -118,6 +119,7 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) { export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) { const popoverRef = useRef(null); + const portalContainer = usePortalContainer(); const stats = useTrackStats(open); const { style } = useFloatingPosition(anchorRef, popoverRef, { @@ -211,6 +213,6 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn )} , - document.body, + portalContainer, ); } diff --git a/packages/web/src/components/voice/ScreenSharePicker.tsx b/packages/web/src/components/voice/ScreenSharePicker.tsx index c2a4dd23..b9f90d21 100644 --- a/packages/web/src/components/voice/ScreenSharePicker.tsx +++ b/packages/web/src/components/voice/ScreenSharePicker.tsx @@ -1,7 +1,9 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { create } from 'zustand'; import { getElectronAPI } from '../../platform/platform'; import { useVoiceStore } from '../../stores/voiceStore'; +import { usePortalContainer } from '../../hooks/usePortalContainer'; // --------------------------------------------------------------------------- // Zustand micro-store — bridges the event-driven API to React state @@ -40,6 +42,7 @@ export function ScreenSharePicker() { const [search, setSearch] = useState(''); const shareAudio = useVoiceStore((s) => s.screenShareConfig.shareAudio); const setScreenShareConfig = useVoiceStore((s) => s.setScreenShareConfig); + const portalContainer = usePortalContainer(); // Register listener for sources from main process (once on mount) useEffect(() => { @@ -100,7 +103,7 @@ export function ScreenSharePicker() { const activeSources = activeTab === 'screens' ? screens : windows; - return ( + return createPortal(
{/* Backdrop */}
- + , + portalContainer, ); } diff --git a/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx b/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx index 4b8e799b..a73b45f7 100644 --- a/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx +++ b/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx @@ -5,6 +5,7 @@ import type { ScreenShareConfig } from '../../stores/voiceStore'; import { useSettingsStore } from '../../stores/settingsStore'; import { buildScreenShareOptions } from '../../utils/screenShare'; import { useFloatingPosition } from '../../hooks/useFloatingPosition'; +import { usePortalContainer } from '../../hooks/usePortalContainer'; import { Toggle } from '../ui/Toggle'; import { isElectron } from '../../platform/platform'; import { RESOLUTION_LABELS } from '@backspace/shared/src/constants'; @@ -46,6 +47,7 @@ function formatKbps(kbps: number): string { export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) { const popoverRef = useRef(null); + const portalContainer = usePortalContainer(); const config = useVoiceStore((s) => s.screenShareConfig); const setConfig = useVoiceStore((s) => s.setScreenShareConfig); const hwOverdrive = useVoiceStore((s) => s.hwOverdrive); @@ -296,6 +298,6 @@ export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenS , - document.body, + portalContainer, ); } diff --git a/packages/web/src/hooks/usePortalContainer.ts b/packages/web/src/hooks/usePortalContainer.ts new file mode 100644 index 00000000..ccc06d8d --- /dev/null +++ b/packages/web/src/hooks/usePortalContainer.ts @@ -0,0 +1,31 @@ +import { useSyncExternalStore } from 'react'; + +// When an element enters browser fullscreen via the Fullscreen API, the user +// agent renders only that element and its descendants. Anything portaled to +// `document.body` (the conventional overlay target) is rendered outside the +// fullscreen layer and is therefore invisible while fullscreen is active. +// +// Overlays that need to remain visible across fullscreen transitions (context +// menus, tooltips, popovers, modals, the screen-share picker) must portal into +// `document.fullscreenElement` while it is non-null and into `document.body` +// otherwise. This hook provides a reactive container that reflects the current +// fullscreen state and re-renders subscribers on every `fullscreenchange`. + +function subscribe(onChange: () => void): () => void { + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); +} + +function getSnapshot(): Element { + return document.fullscreenElement ?? document.body; +} + +function getServerSnapshot(): Element { + // Vite SPA — never invoked, but useSyncExternalStore requires a server snapshot + // for type completeness. Return body so SSR-style renders never crash. + return document.body; +} + +export function usePortalContainer(): Element { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +}