diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index cdb2405d..33f3f899 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -130,7 +130,7 @@ export default function AddToPlaylist({
e.stopPropagation()} >
diff --git a/frontend/src/components/BackToTop.tsx b/frontend/src/components/BackToTop.tsx index 4950495b..016561e4 100644 --- a/frontend/src/components/BackToTop.tsx +++ b/frontend/src/components/BackToTop.tsx @@ -48,7 +48,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr return createPortal(
- {/* Identity + actions. relative+z-10 so the avatar that overlaps up into the banner + {/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner paints ABOVE it (the banner's positioned container would otherwise cover it). */} -
+
diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index 6304563b..21e6c6e7 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -7,6 +7,7 @@ import { onMessage, onUnread } from "../lib/messagesSocket"; import * as e2ee from "../lib/e2ee"; import Avatar from "./Avatar"; import ChatThread from "./ChatThread"; +import Overlay from "./Overlay"; // The Messenger-style floating chat dock: open conversations live here, bottom-right, across // page navigation (state persisted per user). Always mounted (for human users) so it also owns @@ -51,11 +52,13 @@ export default function ChatDock({ meId }: { meId: number }) { if (chats.length === 0) return null; return ( -
- {chats.map((c) => ( - - ))} -
+ +
+ {chats.map((c) => ( + + ))} +
+
); } @@ -65,7 +68,7 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */} {chat.flash > 0 && ( -
+
)}
)}
+ ); } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 7eadae02..857ec4db 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -106,7 +106,7 @@ export default function Header({ // Absolutely positioned within the (relative) content column, so its left edge tracks the // actual left zone: it sits just inside the content, reclaiming the space when the nav rail is // slim and/or no filter sidebar is present — no hard-coded offset. -
+
{open && createPortal( -
+
{menu}
, document.body @@ -127,7 +127,7 @@ export default function LanguageSwitcher({
{LANGUAGES.map((l) => (
+ ); } diff --git a/frontend/src/components/Overlay.tsx b/frontend/src/components/Overlay.tsx new file mode 100644 index 00000000..41730abb --- /dev/null +++ b/frontend/src/components/Overlay.tsx @@ -0,0 +1,14 @@ +import { type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +// The app's overlay-root convention. Any full-screen or floating overlay renders THROUGH this so it +// escapes whatever ancestor stacking context it was declared in — a `mask-image`/`transform`/`filter` +// on the page scroller (or a rail's `backdrop-filter`) would otherwise trap a `fixed` child and let +// the chrome paint over it. That is the exact class of bug that buried the players in E3; the +// modal/player family already dodges it by portaling to , and this makes that the one blessed +// way. Stacking among overlays is then decided by DOM order + the z-index token scale (tailwind +// `zIndex`), never by an ad-hoc magic number. Portals to (the existing de-facto target). +export default function Overlay({ children }: { children: ReactNode }) { + if (typeof document === "undefined") return null; + return createPortal(children, document.body); +} diff --git a/frontend/src/components/PanelGroup.tsx b/frontend/src/components/PanelGroup.tsx index 56cb3595..1b41de35 100644 --- a/frontend/src/components/PanelGroup.tsx +++ b/frontend/src/components/PanelGroup.tsx @@ -39,7 +39,7 @@ export default function PanelGroup({ ref={setNodeRef} style={style} className={`glass-card rounded-xl ${ - isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : "" + isDragging ? "relative z-base shadow-2xl ring-1 ring-accent" : "" } ${hidden && editing ? "opacity-50" : ""}`} >
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index ac400599..dedff98f 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -585,14 +585,14 @@ export default function PlayerModal({ // Portaled to , like every other full-screen overlay here. A fixed, full-viewport modal // must not render inside the page scroller: once that scroller is a stacking context (its - // edge-fade mask makes it one) this z-50 is scoped inside it, and the nav rail (z-40) and the - // header (z-30) paint over the modal — worst at browser zoom, where the centered card is smaller + // edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the + // header (z-chrome) paint over the modal — worst at browser zoom, where the centered card is smaller // relative to the rail/panel. (Same class as PlexPlayer; this one was the miss in that sweep.) return createPortal(
)} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} {playerError == null && nativeControls && ( -
+
{t("player.nativeControls")}
)} {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */} {volumeUi != null && ( -
+
{volumeUi === 0 ? : }
@@ -768,7 +768,7 @@ export default function PlayerModal({ descRect && createPortal(
, document.body)}> + , document.body)}> ); @@ -924,7 +924,7 @@ function PlexShowView({ <> {/* Hero */}
-
+
-
+
, like every other full-screen overlay here (Modal, PlayerModal, ChatDock, // BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the // moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and - // this z-50 is then scoped inside it — the rail, the side panel and the header paint straight + // this z-overlay is then scoped inside it — the rail, the side panel and the header paint straight // over the player. That is exactly what the scroller's edge-fade did before this moved out. return createPortal(
{ e.stopPropagation(); setHelpOpen(false); @@ -1609,7 +1609,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */} {infoOpen && detail && (
{ e.stopPropagation(); setInfoOpen(false); diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index a82c98a2..d4a9eae0 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -56,7 +56,7 @@ function UserShare({ job }: { job: DownloadJob }) { className={inputCls} /> {open && filtered.length > 0 && ( -
+
{filtered.map((u) => ( {expanded && ( -