refactor(z-index): name the stacking layers as a token scale

Replace ~48 magic z-index values (z-10..z-[9999] + 2 inline zIndex) with named
tailwind zIndex tokens (base/menu/chrome/paneltab/panel/rail/overlay/popover/
tooltip/tuner). Values are UNCHANGED — a rename, not a renumber — so layering is
identical; the names give one source of truth for the stack.
This commit is contained in:
2026-07-18 19:11:25 +02:00
parent 7039abc4d3
commit c91caffa5e
26 changed files with 71 additions and 46 deletions
+1 -1
View File
@@ -130,7 +130,7 @@ export default function AddToPlaylist({
<div <div
ref={panelRef} ref={panelRef}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }} style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
className="z-50 glass-menu rounded-xl p-2 shadow-2xl" className="z-overlay glass-menu rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="text-xs text-muted px-1.5 pb-1.5"> <div className="text-xs text-muted px-1.5 pb-1.5">
+1 -1
View File
@@ -48,7 +48,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
return createPortal( return createPortal(
<button <button
onClick={toTop} onClick={toTop}
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${ className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none" show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`} }`}
> >
+3 -3
View File
@@ -260,7 +260,7 @@ export default function ChannelPage({
<button <button
data-testid="channel-back" data-testid="channel-back"
onClick={closeChannel} onClick={closeChannel}
className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm" className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
{t("channel.back")} {t("channel.back")}
@@ -268,9 +268,9 @@ export default function ChannelPage({
</div> </div>
<div className="px-4 sm:px-6"> <div className="px-4 sm:px-6">
{/* 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). */} paints ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-10 flex items-end gap-4 -mt-8"> <div className="relative z-base flex items-end gap-4 -mt-8">
<Avatar <Avatar
src={ch?.thumbnail_url ?? null} src={ch?.thumbnail_url ?? null}
fallback={name} fallback={name}
+1 -1
View File
@@ -742,7 +742,7 @@ function TagsCell({
<div <div
ref={fade.ref} ref={fade.ref}
style={fade.style} style={fade.style}
className={`glass-menu absolute left-0 z-30 w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${ className={`glass-menu absolute left-0 z-chrome w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
openUp ? "bottom-full mb-1" : "top-full mt-1" openUp ? "bottom-full mb-1" : "top-full mt-1"
}`} }`}
> >
+2 -2
View File
@@ -208,7 +208,7 @@ export default function DataTable<T>({
return ( return (
<div <div
ref={popRef} ref={popRef}
className="glass-menu absolute left-0 top-full mt-1 z-30 w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]" className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
> >
{f.kind === "text" && ( {f.kind === "text" && (
<input <input
@@ -373,7 +373,7 @@ export default function DataTable<T>({
away with the collapsed table box in some browsers. The `::after` paints a short away with the collapsed table box in some browsers. The `::after` paints a short
fade just BELOW the header so rows dissolve as they slide under it (the header itself fade just BELOW the header so rows dissolve as they slide under it (the header itself
stays crisp — the page scroller's own top fade is off on these pages). */} stays crisp — the page scroller's own top fade is off on these pages). */}
<thead className="sticky top-0 z-10 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']"> <thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]"> <tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => { {columns.map((col) => {
const sorted_ = sort?.key === col.key; const sorted_ = sort?.key === col.key;
+1 -1
View File
@@ -447,7 +447,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
className={inputCls} className={inputCls}
/> />
{open && filtered.length > 0 && ( {open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden"> <div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
{filtered.map((u) => ( {filtered.map((u) => (
<button <button
key={u.id} key={u.id}
+1 -1
View File
@@ -106,7 +106,7 @@ export default function Header({
// Absolutely positioned within the (relative) content column, so its left edge tracks the // 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 // 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. // slim and/or no filter sidebar is present — no hard-coded offset.
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-30 flex items-center gap-3"> <div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex items-center gap-3">
<ModuleName <ModuleName
label={t(moduleLabelKey[page])} label={t(moduleLabelKey[page])}
labels={moduleLabels} labels={moduleLabels}
+2 -2
View File
@@ -103,7 +103,7 @@ export default function LanguageSwitcher({
</button> </button>
{open && {open &&
createPortal( createPortal(
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}> <div className="z-rail" style={{ position: "fixed", left: pos.left, bottom: pos.bottom }}>
{menu} {menu}
</div>, </div>,
document.body document.body
@@ -127,7 +127,7 @@ export default function LanguageSwitcher({
<div <div
className={`glass-menu absolute ${ className={`glass-menu absolute ${
align === "right" ? "right-0" : "left-0" align === "right" ? "right-0" : "left-0"
} mt-1 w-40 rounded-xl p-1.5 z-40 animate-[popIn_0.16s_ease]`} } mt-1 w-40 rounded-xl p-1.5 z-rail animate-[popIn_0.16s_ease]`}
> >
{LANGUAGES.map((l) => ( {LANGUAGES.map((l) => (
<button <button
+1 -1
View File
@@ -52,7 +52,7 @@ export default function Modal({
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm" className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
onMouseDown={(e) => { onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget; pressedOnBackdrop.current = e.target === e.currentTarget;
}} }}
+2 -2
View File
@@ -278,7 +278,7 @@ export default function NavSidebar({
// margin, and a hover zone out there would fire from the dead space next to the rail — // margin, and a hover zone out there would fire from the dead space next to the rail —
// leaving the side panel to the left would pop the rail open with nothing under the cursor. // leaving the side panel to the left would pop the rail open with nothing under the cursor.
{...peek.handlers} {...peek.handlers}
className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${ className={`glass absolute top-0 bottom-0 left-0 z-rail rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
slim ? "w-[56px] px-2" : "w-52 px-3" slim ? "w-[56px] px-2" : "w-52 px-3"
}`} }`}
aria-label={t("nav.primary")} aria-label={t("nav.primary")}
@@ -390,7 +390,7 @@ export default function NavSidebar({
<div <div
ref={acctPanelRef} ref={acctPanelRef}
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }} style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
className="glass w-60 rounded-xl p-3 z-50 animate-[popIn_0.16s_ease]" className="glass w-60 rounded-xl p-3 z-overlay animate-[popIn_0.16s_ease]"
> >
<div className="flex items-center gap-3 pb-3 border-b border-border"> <div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg <AvatarImg
+1 -1
View File
@@ -39,7 +39,7 @@ export default function PanelGroup({
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`glass-card rounded-xl ${ 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" : ""}`} } ${hidden && editing ? "opacity-50" : ""}`}
> >
<div className="flex items-center gap-1.5 px-3 py-2"> <div className="flex items-center gap-1.5 px-3 py-2">
+7 -7
View File
@@ -585,14 +585,14 @@ export default function PlayerModal({
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal // Portaled to <body>, 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 // 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 // edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
// header (z-30) paint over the modal — worst at browser zoom, where the centered card is smaller // 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.) // relative to the rail/panel. (Same class as PlexPlayer; this one was the miss in that sweep.)
return createPortal( return createPortal(
<div <div
ref={dialogRef} ref={dialogRef}
data-testid="player-modal" data-testid="player-modal"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm" className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -642,21 +642,21 @@ export default function PlayerModal({
}} }}
title={t("player.shortcutsHint")} title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")} aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-10 cursor-pointer ${ className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : "" nativeControls ? "pointer-events-none" : ""
}`} }`}
/> />
)} )}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && ( {playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm"> <div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
<Settings className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")} {t("player.nativeControls")}
</div> </div>
)} )}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */} {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && ( {volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg"> <div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />} {volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25"> <div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} /> <div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} />
@@ -768,7 +768,7 @@ export default function PlayerModal({
descRect && descRect &&
createPortal( createPortal(
<div <div
className="fixed z-[60] bg-surface border border-border rounded-xl shadow-2xl p-4" className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{ style={{
left: descRect.left, left: descRect.left,
bottom: descRect.bottom, bottom: descRect.bottom,
+3 -3
View File
@@ -231,7 +231,7 @@ export default function PlexBrowse({
// The fallback is portaled for the same reason the player itself is: it is a fixed // The fallback is portaled for the same reason the player itself is: it is a fixed
// full-viewport overlay, and this tree renders inside the page scroller. // full-viewport overlay, and this tree renders inside the page scroller.
return ( return (
<Suspense fallback={createPortal(<div className="fixed inset-0 z-50 bg-black" />, document.body)}> <Suspense fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}>
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} /> <PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
</Suspense> </Suspense>
); );
@@ -924,7 +924,7 @@ function PlexShowView({
<> <>
{/* Hero */} {/* Hero */}
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5"> <div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-10"> <div className="absolute right-3 top-3 z-base">
<DetailCustomizeMenu <DetailCustomizeMenu
artBg={artBg} artBg={artBg}
onToggleArtBg={toggleArtBg} onToggleArtBg={toggleArtBg}
@@ -1121,7 +1121,7 @@ function PlexSeasonView({
) : ( ) : (
<> <>
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5"> <div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-10"> <div className="absolute right-3 top-3 z-base">
<DetailCustomizeMenu <DetailCustomizeMenu
artBg={artBg} artBg={artBg}
onToggleArtBg={toggleArtBg} onToggleArtBg={toggleArtBg}
+4 -4
View File
@@ -948,12 +948,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock, // Portaled to <body>, 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 // 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 // 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. // over the player. That is exactly what the scroller's edge-fade did before this moved out.
return createPortal( return createPortal(
<div <div
ref={wrapRef} ref={wrapRef}
className="fixed left-0 top-0 z-50 bg-black flex items-center justify-center select-none" className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
onMouseMove={wake} onMouseMove={wake}
onClick={wake} onClick={wake}
onWheel={onWheel} onWheel={onWheel}
@@ -1546,7 +1546,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */} {/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && ( {helpOpen && (
<div <div
className="absolute inset-0 z-20 grid place-items-center bg-black/60 p-4" className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setHelpOpen(false); 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. */} {/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
{infoOpen && detail && ( {infoOpen && detail && (
<div <div
className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-black/70 p-4" className="absolute inset-0 z-menu grid place-items-center overflow-y-auto bg-black/70 p-4"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setInfoOpen(false); setInfoOpen(false);
+1 -1
View File
@@ -56,7 +56,7 @@ function UserShare({ job }: { job: DownloadJob }) {
className={inputCls} className={inputCls}
/> />
{open && filtered.length > 0 && ( {open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden"> <div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
{filtered.map((u) => ( {filtered.map((u) => (
<button <button
key={u.id} key={u.id}
+2 -2
View File
@@ -60,7 +60,7 @@ export default function SidePanel({
<button <button
onClick={onToggleCollapse} onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")} aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className={`glass glass-hover absolute top-3 left-0 z-[34] flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${ className={`glass glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${
expanded ? "opacity-0" : "" expanded ? "opacity-0" : ""
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`} } ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
> >
@@ -79,7 +79,7 @@ export default function SidePanel({
</button> </button>
{expanded && ( {expanded && (
<aside className="glass absolute top-3 bottom-3 left-0 z-[35] w-64 rounded-2xl flex flex-col overflow-hidden"> <aside className="glass absolute top-3 bottom-3 left-0 z-panel w-64 rounded-2xl flex flex-col overflow-hidden">
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize. {/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */} Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none"> <div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
+1 -1
View File
@@ -134,7 +134,7 @@ export default function SyncStatus({
<div <div
ref={popRef} ref={popRef}
style={{ position: "fixed", top: pos.top, right: pos.right }} style={{ position: "fixed", top: pos.top, right: pos.right }}
className="glass-menu w-64 rounded-xl p-3 z-50 text-xs text-muted animate-[popIn_0.16s_ease]" className="glass-menu w-64 rounded-xl p-3 z-overlay text-xs text-muted animate-[popIn_0.16s_ease]"
> >
<div <div
title={t("header.sync.countTooltip")} title={t("header.sync.countTooltip")}
+2 -2
View File
@@ -82,8 +82,8 @@ function TagRow({
ref={popFade.ref} ref={popFade.ref}
onMouseEnter={openPop} onMouseEnter={openPop}
onMouseLeave={closeSoon} onMouseLeave={closeSoon}
style={{ position: "fixed", left: pos.left, top: pos.top, zIndex: 60, ...popFade.style }} style={{ position: "fixed", left: pos.left, top: pos.top, ...popFade.style }}
className="glass-menu w-56 max-h-72 overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]" className="z-popover glass-menu w-56 max-h-72 overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
> >
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1"> <div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")} {t("tagManager.onChannels")}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function Toaster() {
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
return ( return (
<div className="absolute bottom-4 left-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"> <div className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
{toasts.map((toast) => { {toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
return ( return (
+1 -1
View File
@@ -83,7 +83,7 @@ export default function Tooltip({
top: coords.top, top: coords.top,
transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`, transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`,
}} }}
className="glass pointer-events-none z-[100] w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]" className="glass pointer-events-none z-tooltip w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
> >
{hint} {hint}
</div>, </div>,
+3 -3
View File
@@ -405,7 +405,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
))} ))}
{/* draggable boundary markers */} {/* draggable boundary markers */}
{boundaries.map((b, bi) => ( {boundaries.map((b, bi) => (
<div key={bi} className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-10" style={{ left: pct(b) }}> <div key={bi} className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base" style={{ left: pct(b) }}>
<div <div
className="w-1 h-full bg-white/90 cursor-ew-resize rounded" className="w-1 h-full bg-white/90 cursor-ew-resize rounded"
onPointerDown={(e) => { onPointerDown={(e) => {
@@ -426,13 +426,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
</div> </div>
))} ))}
{/* playhead */} {/* playhead */}
<div className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-20" style={{ left: pct(current) }} /> <div className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu" style={{ left: pct(current) }} />
</div> </div>
{/* hover-scrub thumbnail */} {/* hover-scrub thumbnail */}
{strip && hoverTime != null && ( {strip && hoverTime != null && (
<div <div
className="absolute bottom-full mb-2 pointer-events-none z-30 rounded-md overflow-hidden border border-border shadow-xl bg-black" className="absolute bottom-full mb-2 pointer-events-none z-chrome rounded-md overflow-hidden border border-border shadow-xl bg-black"
style={{ left: hoverLeft, width: HOVER_W }} style={{ left: hoverLeft, width: HOVER_W }}
> >
<div style={{ width: HOVER_W, height: HOVER_W / aspect, ...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))) }} /> <div style={{ width: HOVER_W, height: HOVER_W / aspect, ...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))) }} />
+1 -1
View File
@@ -119,7 +119,7 @@ export default function ViewSwitcher<T extends string>({
role="menu" role="menu"
aria-label={label} aria-label={label}
onKeyDown={onMenuKeyDown} onKeyDown={onMenuKeyDown}
className="glass-menu absolute right-0 top-full mt-1 z-30 min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]" className="glass-menu absolute right-0 top-full mt-1 z-chrome min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
> >
{/* The modes are one exclusive choice, so the radio items need a group to scope their {/* The modes are one exclusive choice, so the radio items need a group to scope their
checked-ness to — otherwise they read as independent checkable items. */} checked-ness to — otherwise they read as independent checkable items. */}
+1 -1
View File
@@ -228,7 +228,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
}, [onClose]); }, [onClose]);
return ( return (
<div <div
className="fixed inset-0 z-[60] grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8" className="fixed inset-0 z-popover grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8"
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
+2 -2
View File
@@ -37,11 +37,11 @@ export function DraftSaveBar({
const saving = state === "saving"; const saving = state === "saving";
const floating = variant === "floating"; const floating = variant === "floating";
const wrap = floating const wrap = floating
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]" ? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40"; : "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
// The floating variant is fixed to the viewport but renders from a page inside the page // The floating variant is fixed to the viewport but renders from a page inside the page
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade // scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
// mask) scopes this z-40 and fades the bar's bottom edge. The inline variant is in normal flow // mask) scopes this z-rail and fades the bar's bottom edge. The inline variant is in normal flow
// and stays put. // and stays put.
const bar = ( const bar = (
<div data-testid="draft-save-bar" className={wrap}> <div data-testid="draft-save-bar" className={wrap}>
+1 -1
View File
@@ -141,7 +141,7 @@ export function DetailCustomizeMenu({
{open && ( {open && (
<div <div
ref={menuRef} ref={menuRef}
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm" className="glass-menu absolute right-0 top-full z-menu mt-1 w-52 rounded-xl p-1.5 text-sm"
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }} style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
> >
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} /> <PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
+25
View File
@@ -16,6 +16,31 @@ export default {
fontFamily: { fontFamily: {
sans: ["Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"], sans: ["Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
}, },
// Named z-index layers — the single source of truth for stacking order. Values are
// deliberately UNCHANGED from the magic numbers they replaced (this was a rename, not a
// renumber), so the visual layering is identical; the names just make the stack legible and
// give future changes one place to edit. Low → high:
// base(10) in-content decorations: sticky table head, card badges, in-flow dropdowns
// menu(20) menus/overlays sitting just over content
// chrome(30) floating chrome: the header band, back-to-top, content filter/sort menus
// paneltab / panel (34/35) the collapsed tab vs the expanded side panel (aside over tab)
// rail(40) full-height rails + docked bars: nav rail, chat dock, floating save bar
// overlay(50) full-screen modals/players, toaster, onboarding, popovers over content
// popover(60) a popover that must sit over an overlay (player scrub, tag menu) + the login gate
// tooltip(100) tooltips, above everything interactive
// tuner(9999) the dev/lab glass tuner, deliberately on top of all
zIndex: {
base: "10",
menu: "20",
chrome: "30",
paneltab: "34",
panel: "35",
rail: "40",
overlay: "50",
popover: "60",
tooltip: "100",
tuner: "9999",
},
}, },
}, },
plugins: [], plugins: [],