Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 228c495d3a05aac062ce0c7db849a941dfe9add7
parent 2dbc7d622f624438b2aa16264745aeda82055089
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 20:00:32 -0400

player: the modal is a column with a labelled strip at the bottom

The transcript modal reads top-down again — title, mode, cues, then the
actions — and the actions are lucide icons with words next to them
instead of thirteen unlabelled emoji whose only explanation was a
tooltip no touch device shows. Below sm the column takes the whole
screen under a full-bleed player, so the cue list is 410px instead of
about a hundred. Mini, Hide and Close never scroll out of reach.

The download-format menu moves to a portalled dropdown, since an
absolutely-positioned panel would be clipped by the scrolling strip;
the modal and the player now read their width and top edge from the
same two CSS variables; and sheets move above the player in the stack
so one can open over it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
Mcommon/components/PlayerProvider.tsx | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcommon/components/TranscriptModal.tsx | 439+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcommon/components/ui/sheet.tsx | 9+++++++--
3 files changed, 338 insertions(+), 183 deletions(-)

diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx @@ -10,6 +10,8 @@ import { useReducer, useRef, useState, + type ComponentProps, + type ReactNode, } from "react"; import type ReactPlayerType from "react-player"; import { fetchTranscript } from "./transcriptCache"; @@ -921,11 +923,17 @@ export function PlayerProvider({ let containerClass = ""; if (displayMode === "modal") { + // Width and top edge come from --player-w/--player-top (globals.css), the + // same two variables TranscriptModal uses to reserve this slot. Below sm + // that is a full-bleed 100vw video tucked under the notch; from sm it is + // the centred min(92vw,960px) card it has always been. containerClass = - "fixed left-1/2 -translate-x-1/2 top-16 w-[min(92vw,960px)] aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl"; + "fixed left-1/2 -translate-x-1/2 top-(--player-top) w-(--player-w) aspect-video z-[60] rounded-lg max-sm:rounded-none overflow-hidden bg-black shadow-2xl"; } else if (displayMode === "mini") { + // Narrower on a phone (a 288px mini over a 390px viewport buries the page) + // and lifted clear of the home indicator. containerClass = - "fixed bottom-4 right-4 w-72 sm:w-96 aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl ring-1 ring-black/40"; + "fixed bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] right-4 w-44 sm:w-96 aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl ring-1 ring-black/40"; } else { // hidden — keep iframe rendered with size so YT keeps playing, // but pull it offscreen and remove from interaction. @@ -1095,7 +1103,7 @@ function HiddenControls({ onClose: () => void; }) { return ( - <div className="fixed bottom-4 right-4 z-[60] flex items-center gap-2 rounded-full bg-zinc-900/95 text-white shadow-2xl ring-1 ring-white/10 px-3 py-2 max-w-[min(90vw,28rem)]"> + <div className="fixed bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] right-4 z-[60] flex items-center gap-2 rounded-full bg-zinc-900/95 text-white shadow-2xl ring-1 ring-white/10 px-3 py-2 max-w-[min(90vw,28rem)] max-sm:inset-x-3 max-sm:right-auto max-sm:max-w-none"> <span className="text-xs opacity-70 shrink-0">♪</span> <span className="text-xs font-medium truncate flex-1 min-w-0"> {title} @@ -1107,35 +1115,74 @@ function HiddenControls({ ); } +// One control in the player/modal chrome. +// +// `title` is BOTH the tooltip and the accessible name (`aria-label`), and every +// one of those strings is an e2e selector — so a control may gain an icon and a +// visible label, but its `title` never changes. +// +// `icon` + `label` are the modal strip's shape: a lucide glyph with a short word +// beside it, because a tooltip is invisible on touch and 13 unlabelled emoji are +// not a control bar. `char` stays for the places that are genuinely too small +// for a label (the hidden-player pill). +// +// Extra props are spread so the button can be a Radix `asChild` trigger (the +// download-format menu): Slot merges its `aria-expanded`/`data-state`/composed +// `onClick` into these props, and they have to reach the real <button>. +type ControlButtonProps = Omit< + ComponentProps<"button">, + "title" | "children" +> & { + title: string; + char?: string; + icon?: ReactNode; + label?: string; + /** Extra classes on the visible label — `max-sm:hidden` for the controls that + * have to stay narrow on a phone. The button keeps ONE identity either way; + * rendering a second, differently-labelled copy per breakpoint would give + * every `getByRole("button", { name })` two matches. */ + labelClass?: string; + highlight?: boolean; +}; + function ControlButton({ title, onClick, char, + icon, + label, + labelClass, disabled, highlight, -}: { - title: string; - onClick: () => void; - char: string; - disabled?: boolean; - highlight?: boolean; -}) { + className, + ...rest +}: ControlButtonProps) { return ( <button + {...rest} type="button" title={title} aria-label={title} onClick={onClick} disabled={disabled} - className={`shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-md text-sm font-mono transition ${ + className={`shrink-0 inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-mono transition ${ + label ? "h-10 px-3 sm:h-9 sm:px-2.5" : "w-10 h-10 sm:w-8 sm:h-8" + } ${ disabled ? "bg-white/5 text-white/30 cursor-not-allowed" : highlight ? "bg-amber-500/30 hover:bg-amber-500/50 text-white ring-1 ring-amber-400/60" : "bg-white/10 hover:bg-white/20 text-white" - }`} + } ${className ?? ""}`} > - {char} + {icon ?? char} + {label && ( + <span + className={`font-sans text-xs whitespace-nowrap ${labelClass ?? ""}`} + > + {label} + </span> + )} </button> ); } diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx @@ -3,12 +3,34 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { + ArrowLeftToLineIcon, + ArrowRightToLineIcon, + CheckIcon, + ClipboardIcon, + DownloadIcon, + ExternalLinkIcon, + LinkIcon, + MessageSquareIcon, + MinusIcon, + PictureInPicture2Icon, + ScrollTextIcon, + SparklesIcon, + TerminalIcon, + XIcon, +} from "lucide-react"; +import { ControlButton, toHMS, usePlayer, usePlayerTime, type DigestStatus, } from "./PlayerProvider"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; import type { VideoDigest } from "../lib/digests"; import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; import { VirtualRow } from "./VirtualRow"; @@ -59,8 +81,9 @@ export default function TranscriptModal() { const shareResetRef = useRef<number | null>(null); const [mdCopied, setMdCopied] = useState(false); const mdResetRef = useRef<number | null>(null); + // Controlled only so the trigger can render its open highlight; dismissal + // (outside click, Escape, focus) is the Radix menu's job. const [downloadOpen, setDownloadOpen] = useState(false); - const downloadRef = useRef<HTMLDivElement | null>(null); // Tracks whether the next scrollToIndex should animate. Smooth on // user-initiated changes (cue click, mode toggle, initial open); instant // on natural playhead drift so 4 Hz progress ticks don't keep retriggering @@ -73,28 +96,6 @@ export default function TranscriptModal() { ? (chatCues?.length ?? 0) > 0 : (data?.cues?.length ?? 0) > 0; - // Close the download format menu on an outside click or Escape. - useEffect(() => { - if (!downloadOpen) return; - const onDown = (e: MouseEvent) => { - if ( - downloadRef.current && - !downloadRef.current.contains(e.target as Node) - ) { - setDownloadOpen(false); - } - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setDownloadOpen(false); - }; - document.addEventListener("mousedown", onDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDown); - document.removeEventListener("keydown", onKey); - }; - }, [downloadOpen]); - // Pre-compute author/body split once per cue array so the render hot path // doesn't redo `indexOf`/`slice` on every progress tick. Memo key is the // identity of the underlying cues array. @@ -154,6 +155,11 @@ export default function TranscriptModal() { scrollKindRef.current = "auto"; }, [activeIndex, modalOpen, modalMode, rowVirtualizer]); + // Lock the page behind the modal. Radix's RemoveScroll (sheets, and the + // download menu's modal layer) also writes to <body> — the two never overlap, + // because nothing that uses RemoveScroll opens *over* this modal. If that ever + // changes, the restore below would hand back a value RemoveScroll had already + // replaced. useEffect(() => { if (modalOpen) { const prev = document.body.style.overflow; @@ -225,6 +231,50 @@ export default function TranscriptModal() { const virtualItems = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); + const iconSize = "size-4"; + + // The mode strip: the same two toggles as before (each falls back to the + // transcript), lifted out of the action bar into their own row so the modes + // read as a chooser rather than as two more emoji among thirteen. `title` — + // and so the accessible name — is unchanged and still depends on the mode. + const modeStrip = ( + <div className="inline-flex w-fit items-center gap-1 rounded-lg bg-zinc-900/80 ring-1 ring-white/10 p-1 pointer-events-auto"> + {/* Only offered where a digest exists. ~0.1% of the corpus is digested, + so an always-present control would be a dead end on almost every + video. */} + {digestAvailable && ( + <ControlButton + title={isDigest ? "Show transcript" : "Show AI chapters"} + onClick={onToggleDigest} + icon={ + isDigest ? ( + <ScrollTextIcon className={iconSize} /> + ) : ( + <SparklesIcon className={iconSize} /> + ) + } + label={isDigest ? "Transcript" : "AI chapters"} + char={isDigest ? "\u{1F4DC}" : "\u2726"} + highlight={isDigest} + /> + )} + <ControlButton + title={isChat ? "Show transcript" : "Show live chat"} + onClick={onToggleChat} + icon={ + isChat ? ( + <ScrollTextIcon className={iconSize} /> + ) : ( + <MessageSquareIcon className={iconSize} /> + ) + } + label={isChat ? "Transcript" : "Live chat"} + char={isChat ? "\u{1F4DC}" : "\u{1F4AC}"} + highlight={isChat} + /> + </div> + ); + return ( <div className="fixed inset-0 z-50 flex flex-col"> <button @@ -233,156 +283,30 @@ export default function TranscriptModal() { onClick={() => setDisplayMode("mini")} className="absolute inset-0 bg-black/70 backdrop-blur-sm" /> - <div className="relative flex flex-col items-center w-full h-full overflow-hidden pt-4 pointer-events-none"> + <div className="relative flex flex-col items-center w-full h-full overflow-hidden pointer-events-none"> + {/* The column that sits UNDER the player. Its top margin and its height + are derived from the same --player-w/--player-top the player itself + is positioned with (globals.css), so the two can't drift apart. + Below sm it takes the full remaining height, which is what makes the + cue list tall and the action strip land on the bottom edge. */} <div - className="w-[min(92vw,960px)] pointer-events-auto flex flex-col gap-3" + className="w-(--player-w) pointer-events-auto flex flex-col gap-2 sm:gap-3 max-sm:h-full" style={{ - marginTop: "calc(min(92vw, 960px) * 9 / 16 + 3rem)", + marginTop: + "calc(var(--player-top) + var(--player-w) * 9 / 16 + 0.75rem)", maxHeight: - "calc(100vh - (min(92vw, 960px) * 9 / 16) - 5rem)", + "calc(100dvh - var(--player-top) - var(--player-w) * 9 / 16 - 1.5rem)", }} > - <div className="flex items-center gap-2 flex-wrap rounded-lg bg-zinc-900/80 ring-1 ring-white/10 p-2"> - <ControlButton - title="Mark clip start at current time" - onClick={markClipStart} - char="[" - highlight={clipStart !== null} - /> - <ControlButton - title="Mark clip end at current time" - onClick={markClipEnd} - char="]" - highlight={clipEnd !== null} - /> - <ClipReadout - clipStart={clipStart} - clipEnd={clipEnd} - onClear={clearClip} - /> - <ControlButton - title={ - canDownload - ? copied - ? "Copied!" - : "Copy yt-dlp download command for this clip" - : "Mark both clip start and end first" - } - onClick={onCopy} - char={copied ? "✓" : "↓"} - disabled={!canDownload} - /> - <div className="flex-1" /> - {/* Only offered where a digest exists. ~0.1% of the corpus is - digested, so an always-present control would be a dead end on - almost every video. */} - {digestAvailable && ( - <ControlButton - title={isDigest ? "Show transcript" : "Show AI chapters"} - onClick={onToggleDigest} - char={isDigest ? "📜" : "✦"} - highlight={isDigest} - /> - )} - <ControlButton - title={isChat ? "Show transcript" : "Show live chat"} - onClick={onToggleChat} - char={isChat ? "📜" : "💬"} - highlight={isChat} - /> - <ControlButton - title={ - !data - ? "Loading…" - : shareCopied - ? "Copied!" - : "Copy share link at current time" - } - onClick={onShare} - char={shareCopied ? "✓" : "⤴"} - disabled={!data} - /> - <div className="relative" ref={downloadRef}> - <ControlButton - title={ - canDownloadFile - ? `Download this ${isChat ? "live chat" : "transcript"} as a file` - : "Nothing to download yet" - } - onClick={() => setDownloadOpen((v) => !v)} - char="⤓" - disabled={!canDownloadFile} - highlight={downloadOpen} - /> - {downloadOpen && ( - <div className="absolute right-0 top-9 z-10 flex flex-col overflow-hidden rounded-md bg-zinc-900 shadow-xl ring-1 ring-white/15"> - {(["txt", "srt", "json"] as const).map((fmt) => ( - <button - key={fmt} - type="button" - onClick={() => { - downloadTranscriptFile(fmt); - setDownloadOpen(false); - }} - className="px-3 py-1.5 text-left font-mono text-xs uppercase tracking-wide text-zinc-200 hover:bg-white/10" - > - .{fmt} - </button> - ))} - </div> - )} - </div> - <ControlButton - title={ - !canDownloadFile - ? "Nothing to copy yet" - : mdCopied - ? "Copied!" - : `Copy this ${isChat ? "live chat" : "transcript"} as Markdown (for AI)` - } - onClick={async () => { - const ok = await copyTranscriptMarkdown(); - if (!ok) return; - setMdCopied(true); - if (mdResetRef.current) window.clearTimeout(mdResetRef.current); - mdResetRef.current = window.setTimeout(() => setMdCopied(false), 1500); - }} - char={mdCopied ? "✓" : "📋"} - disabled={!canDownloadFile} - /> - {data?.platform === "youtube" && ( - <ControlButton - title="Open in Preservetube" - onClick={openInPreservetube} - char="⧉" - /> - )} - <ControlButton - title="Collapse to mini-player" - onClick={() => setDisplayMode("mini")} - char="↘" - /> - <ControlButton - title="Hide player (keep audio playing)" - onClick={() => setDisplayMode("hidden")} - char="—" - /> - <ControlButton - title="Close player" - onClick={closePlayer} - char="✕" - /> - </div> - - <div className="text-white min-w-0"> - <h2 className="text-lg font-semibold leading-tight truncate"> - {data?.title ?? "Loading…"} + <div className="text-white min-w-0 px-3 sm:px-0"> + <h2 className="text-lg font-semibold leading-tight line-clamp-2 sm:truncate"> + {data?.title ?? "Loading\u2026"} </h2> {data && ( <p className="text-xs text-zinc-300 mt-0.5 flex items-center gap-1.5 flex-wrap"> <span> {formatDate(data.uploadDate)} - {data.channel && ` · ${data.channel}`} + {data.channel && ` \u00b7 ${data.channel}`} </span> {data.isLivestream && <LivestreamBadge />} {data.ageRestricted && <AgeRestrictedBadge />} @@ -390,17 +314,20 @@ export default function TranscriptModal() { )} </div> + <div className="px-3 sm:px-0">{modeStrip}</div> + {modalNotice && ( <p role="status" - className="text-xs text-amber-200 bg-amber-500/15 ring-1 ring-amber-400/40 rounded px-3 py-1.5" + className="mx-3 sm:mx-0 text-xs text-amber-200 bg-amber-500/15 ring-1 ring-amber-400/40 rounded px-3 py-1.5" > {modalNotice} </p> )} <div ref={scrollRef} - className="flex-1 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900" + data-testid="cue-list" + className="flex-1 min-h-0 overflow-y-auto overscroll-contain touch-pan-y border border-zinc-700 bg-zinc-900 rounded-none sm:rounded-lg" > {isDigest ? ( <DigestPanel @@ -413,7 +340,7 @@ export default function TranscriptModal() { <> {cueStatus === "loading" && ( <p className="text-sm text-zinc-400 p-4"> - {isChat ? "Loading live chat…" : "Loading transcript…"} + {isChat ? "Loading live chat\u2026" : "Loading transcript\u2026"} </p> )} {cueStatus === "ready" && displayCues.length === 0 && ( @@ -446,12 +373,188 @@ export default function TranscriptModal() { </> )} </div> + + {/* Action strip, pinned to the bottom edge. The clip/share group + scrolls sideways when it doesn't fit; the window group never does, + so Close is reachable at any width without a horizontal scroll. */} + <div className="shrink-0 sticky bottom-0 flex items-stretch gap-2 bg-zinc-900/80 ring-1 ring-white/10 rounded-none sm:rounded-lg p-2 pb-[calc(0.5rem+env(safe-area-inset-bottom,0px))] sm:pb-2"> + <div className="flex-1 min-w-0 flex items-center gap-2 overflow-x-auto"> + <ControlButton + title="Mark clip start at current time" + onClick={markClipStart} + icon={<ArrowLeftToLineIcon className={iconSize} />} + label="Clip in" + char="[" + highlight={clipStart !== null} + /> + <ControlButton + title="Mark clip end at current time" + onClick={markClipEnd} + icon={<ArrowRightToLineIcon className={iconSize} />} + label="Clip out" + char="]" + highlight={clipEnd !== null} + /> + <ClipReadout + clipStart={clipStart} + clipEnd={clipEnd} + onClear={clearClip} + /> + <ControlButton + title={ + canDownload + ? copied + ? "Copied!" + : "Copy yt-dlp download command for this clip" + : "Mark both clip start and end first" + } + onClick={onCopy} + icon={ + copied ? ( + <CheckIcon className={iconSize} /> + ) : ( + <TerminalIcon className={iconSize} /> + ) + } + label="yt-dlp" + char={copied ? "\u2713" : "\u2193"} + disabled={!canDownload} + /> + + <Divider /> + + <ControlButton + title={ + !data + ? "Loading\u2026" + : shareCopied + ? "Copied!" + : "Copy share link at current time" + } + onClick={onShare} + icon={ + shareCopied ? ( + <CheckIcon className={iconSize} /> + ) : ( + <LinkIcon className={iconSize} /> + ) + } + label="Share" + char={shareCopied ? "\u2713" : "\u2934"} + disabled={!data} + /> + {/* A portalled menu, because the trigger now lives inside a + horizontally scrolling strip: an absolutely-positioned panel + would be clipped by that scroller. */} + <DropdownMenu open={downloadOpen} onOpenChange={setDownloadOpen}> + <DropdownMenuTrigger asChild> + <ControlButton + title={ + canDownloadFile + ? `Download this ${isChat ? "live chat" : "transcript"} as a file` + : "Nothing to download yet" + } + icon={<DownloadIcon className={iconSize} />} + label="Download" + char="\u2913" + disabled={!canDownloadFile} + highlight={downloadOpen} + /> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="z-[70] min-w-0"> + {(["txt", "srt", "json"] as const).map((fmt) => ( + <DropdownMenuItem + key={fmt} + onSelect={() => downloadTranscriptFile(fmt)} + className="font-mono text-xs uppercase tracking-wide" + > + .{fmt} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + <ControlButton + title={ + !canDownloadFile + ? "Nothing to copy yet" + : mdCopied + ? "Copied!" + : `Copy this ${isChat ? "live chat" : "transcript"} as Markdown (for AI)` + } + onClick={async () => { + const ok = await copyTranscriptMarkdown(); + if (!ok) return; + setMdCopied(true); + if (mdResetRef.current) window.clearTimeout(mdResetRef.current); + mdResetRef.current = window.setTimeout(() => setMdCopied(false), 1500); + }} + icon={ + mdCopied ? ( + <CheckIcon className={iconSize} /> + ) : ( + <ClipboardIcon className={iconSize} /> + ) + } + label="Copy MD" + char={mdCopied ? "\u2713" : "\u{1F4CB}"} + disabled={!canDownloadFile} + /> + {data?.platform === "youtube" && ( + <ControlButton + title="Open in Preservetube" + onClick={openInPreservetube} + icon={<ExternalLinkIcon className={iconSize} />} + label="Preservetube" + char="\u29c9" + /> + )} + </div> + + <Divider /> + + {/* Mini / Hide / Close: the labels drop away on a phone so the + group stays narrow enough to sit beside the scroller — Close is + then reachable at any width without a sideways scroll. One + button each; a per-breakpoint duplicate would give every + `getByRole("button", { name })` two matches. */} + <div className="shrink-0 flex items-center gap-1 sm:gap-2"> + <ControlButton + title="Collapse to mini-player" + onClick={() => setDisplayMode("mini")} + icon={<PictureInPicture2Icon className={iconSize} />} + label="Mini" + labelClass="max-sm:hidden" + char="\u2198" + /> + <ControlButton + title="Hide player (keep audio playing)" + onClick={() => setDisplayMode("hidden")} + icon={<MinusIcon className={iconSize} />} + label="Hide" + labelClass="max-sm:hidden" + char="\u2014" + /> + <ControlButton + title="Close player" + onClick={closePlayer} + icon={<XIcon className={iconSize} />} + label="Close" + labelClass="max-sm:hidden" + char="\u2715" + /> + </div> + </div> </div> </div> </div> ); } +// A hairline between control groups in the action strip. +function Divider() { + return <div className="w-px self-stretch shrink-0 bg-white/10" />; +} + // The digest panel: chapters as a plain (non-virtualized) list, the topic tags, // a provenance line, and — when the digest was borrowed from another video — a // prominent notice saying so. @@ -639,13 +742,13 @@ function ClipReadout({ }) { if (clipStart === null && clipEnd === null) { return ( - <span className="text-xs text-zinc-500 font-mono px-1"> + <span className="shrink-0 whitespace-nowrap text-xs text-zinc-500 font-mono px-1"> no clip set </span> ); } return ( - <span className="text-xs font-mono text-zinc-200 px-1 inline-flex items-center gap-1"> + <span className="shrink-0 whitespace-nowrap text-xs font-mono text-zinc-200 px-1 inline-flex items-center gap-1"> <span>{clipStart !== null ? toHMS(clipStart) : "--:--:--"}</span> <span className="opacity-60">→</span> <span>{clipEnd !== null ? toHMS(clipEnd) : "--:--:--"}</span> diff --git a/common/components/ui/sheet.tsx b/common/components/ui/sheet.tsx @@ -36,7 +36,11 @@ function SheetOverlay({ <SheetPrimitive.Overlay data-slot="sheet-overlay" className={cn( - "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", + // z-[70], not z-50: the floating player is z-[60] (PlayerProvider) and + // the transcript modal z-50. A sheet has to open OVER the player — + // otherwise the mini player sits on top of it and, with Radix's + // `pointer-events: none` on <body>, nothing in the sheet is clickable. + "fixed inset-0 z-[70] bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", className )} {...props} @@ -60,7 +64,8 @@ function SheetContent({ <SheetPrimitive.Content data-slot="sheet-content" className={cn( - "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500", + // See SheetOverlay: above the z-[60] player, not below it. + "fixed z-[70] flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500", side === "right" && "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", side === "left" &&