refactor(overlay): single overlay-root convention via Overlay helper
Add components/Overlay.tsx (portals to <body>) and route the in-tree full-screen/ floating holdouts through it — ChatDock, OnboardingWizard, GlassTuner — so they escape any ancestor stacking context (mask/transform/backdrop-filter) instead of relying on a bare high z-index. Toaster stays in-tree (it's content-column-scoped by design, outside the masked scroller); Welcome is a page, not an overlay.
This commit is contained in:
@@ -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 (
|
||||
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
|
||||
{chats.map((c) => (
|
||||
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
|
||||
))}
|
||||
</div>
|
||||
<Overlay>
|
||||
<div className="fixed bottom-0 right-0 z-rail flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
|
||||
{chats.map((c) => (
|
||||
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
|
||||
))}
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +68,7 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
||||
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
|
||||
{chat.flash > 0 && (
|
||||
<div key={chat.flash} className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-10" />
|
||||
<div key={chat.flash} className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-base" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Overlay from "./Overlay";
|
||||
|
||||
/**
|
||||
* A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
|
||||
@@ -216,7 +217,8 @@ export default function GlassTuner({ enabled }: { enabled: boolean }) {
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
|
||||
<Overlay>
|
||||
<div className="fixed right-0 top-24 z-tuner flex items-start" style={{ pointerEvents: "none" }}>
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => persistOpen(true)}
|
||||
@@ -335,6 +337,7 @@ export default function GlassTuner({ enabled }: { enabled: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
import Overlay from "./Overlay";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
||||
@@ -122,7 +123,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center p-4">
|
||||
<Overlay>
|
||||
<div className="fixed inset-0 z-overlay grid place-items-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]" onClick={close} />
|
||||
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
|
||||
<button
|
||||
@@ -256,5 +258,6 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <body>, 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 <body> (the existing de-facto target).
|
||||
export default function Overlay({ children }: { children: ReactNode }) {
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
Reference in New Issue
Block a user