Un-nest the dock panels: crisp in Firefox, and side by side

The console panel lived inside the bar, so its centring translate and its
backdrop filter were both nested inside the bar's own — a half-pixel offset
under a blurred layer, which is what Gecko was rasterising the text through.
Panels and bar are siblings in one column now.

That column is also what the issues list needed: it was a popover anchored to
its trigger, opaque where the console is frosted and landing on top of it when
both were open. It is the same panel now, the same width, in the same row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:15:26 +02:00
co-authored by Claude Opus 5
parent fd69654857
commit 721b89f5eb
2 changed files with 420 additions and 340 deletions
+296 -235
View File
@@ -13,15 +13,11 @@ import {
WifiOff, WifiOff,
X, X,
} from "lucide-react" } from "lucide-react"
import { motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import type { ValidationIssue } from "@/client" import type { ValidationIssue } from "@/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { import {
Tooltip, Tooltip,
@@ -30,7 +26,7 @@ import {
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion" import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { type LogsFilter, LogsPanel } from "./LogsPanel" import { type LogsFilter, LogsPanel, LogsTrigger, PANEL } from "./LogsPanel"
import { useLiveConnection } from "./liveStore" import { useLiveConnection } from "./liveStore"
/** /**
@@ -110,271 +106,336 @@ export function FlowDock({
}) { }) {
const { fitView } = useReactFlow() const { fitView } = useReactFlow()
const connected = useLiveConnection() const connected = useLiveConnection()
const [issuesOpen, setIssuesOpen] = useState(false)
// The engine marks the issues it does not count against a flow. They are // The engine marks the issues it does not count against a flow. They are
// still worth saying, so they stay in the list — but in the muted tone, and // still worth saying, so they stay in the list — but in the muted tone, and
// without turning the summary red, since nothing here is actually broken. // without turning the summary red, since nothing here is actually broken.
const faults = issues.filter((issue) => !issue.advisory) const faults = issues.filter((issue) => !issue.advisory)
return ( return (
<motion.div // The panels and the bar are siblings in one column, so neither nests its
variants={slideUp} // half-pixel centring translate or its backdrop filter inside the other —
initial="hidden" // which is what Firefox was drawing the console's text through. It also
animate="visible" // gives the two panels a row to share, instead of one landing on the other.
exit="exit" <div
transition={transitions.emphasized}
// Capped and wrapping: the canvas shell clips, so an uncapped row would
// put the buttons at its ends out of reach on a phone rather than merely
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
className={cn( className={cn(
"pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]", "pointer-events-none absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 flex-col items-center",
className, className,
)} )}
> >
<Tooltip> <div className="mb-3 flex max-w-[calc(100vw-2rem)] flex-wrap items-end justify-center gap-3">
<TooltipTrigger asChild> <IssuesPanel
<Button open={issuesOpen}
variant="ghost" issues={issues}
size="icon" onClose={() => setIssuesOpen(false)}
className="size-11 text-muted-foreground md:size-8" onFocusNode={onFocusNode}
onClick={onAddNode} />
aria-label="Add node" <LogsPanel flow={flow} {...logs} />
data-testid="add-node" </div>
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>Add a node (K)</TooltipContent>
</Tooltip>
<Separator <motion.div
orientation="vertical" variants={slideUp}
className="mx-0.5 !h-5 hidden md:block" initial="hidden"
/> animate="visible"
exit="exit"
transition={transitions.emphasized}
// Capped and wrapping: the canvas shell clips, so an uncapped row would
// put the buttons at its ends out of reach on a phone rather than merely
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
className="pointer-events-auto flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onAddNode}
aria-label="Add node"
data-testid="add-node"
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>Add a node (K)</TooltipContent>
</Tooltip>
{/* A phone pinches to zoom and the graph fits itself, so this would only <Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
{/* A phone pinches to zoom and the graph fits itself, so this would only
be taking room the rest of the bar needs. */} be taking room the rest of the bar needs. */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="hidden text-muted-foreground md:inline-flex md:size-8" className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })} onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
aria-label="Fit the flow to the screen" aria-label="Fit the flow to the screen"
> >
<Maximize2 /> <Maximize2 />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Fit to screen</TooltipContent> <TooltipContent>Fit to screen</TooltipContent>
</Tooltip> </Tooltip>
{issues.length > 0 ? ( {issues.length > 0 ? (
<> <>
<Separator <Separator
orientation="vertical" orientation="vertical"
className="mx-0.5 !h-5 hidden md:block" className="mx-0.5 !h-5 hidden md:block"
/> />
<Popover> <Button
<PopoverTrigger asChild> variant="ghost"
size="sm"
className={cn(
"h-11 gap-1.5 md:h-8",
faults.length > 0
? "text-destructive"
: issuesOpen
? "text-primary"
: "text-muted-foreground",
)}
onClick={() => setIssuesOpen(!issuesOpen)}
aria-pressed={issuesOpen}
data-testid="validation-summary"
>
<AlertCircle className="size-4" />
{issues.length}
</Button>
</>
) : null}
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<LogsTrigger open={logs.open} onOpenChange={logs.onOpenChange} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"size-11 text-muted-foreground md:size-8",
paused && "text-primary",
)}
onClick={onTogglePause}
disabled={!enabled}
aria-label={paused ? "Resume flow" : "Pause flow"}
data-testid={paused ? "resume-flow" : "pause-flow"}
>
{paused ? <PlayCircle /> : <Pause />}
</Button>
</TooltipTrigger>
<TooltipContent>
{!enabled
? "This flow is stopped"
: paused
? "Let the flow carry on"
: "Hold the nodes; values still arrive"}
</TooltipContent>
</Tooltip>
{paused && enabled ? (
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
className={cn( className="size-11 text-muted-foreground md:size-8"
"h-11 gap-1.5 md:h-8", onClick={onStep}
faults.length > 0 disabled={stepping}
? "text-destructive" aria-label="Step the flow"
: "text-muted-foreground", data-testid="step-flow"
)}
data-testid="validation-summary"
> >
<AlertCircle className="size-4" /> <StepForward />
{issues.length}
</Button> </Button>
</PopoverTrigger> </TooltipTrigger>
<PopoverContent align="center" className="w-80 p-2"> <TooltipContent>Let one held-back item through</TooltipContent>
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"> </Tooltip>
Needs attention ) : null}
</p>
<ul className="mt-1 grid gap-0.5">
{issues.map((issue) => (
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
<button
type="button"
className={cn(
"w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent",
issue.advisory && "text-muted-foreground",
)}
onClick={() => issue.node && onFocusNode(issue.node)}
disabled={!issue.node}
>
{issue.message}
</button>
</li>
))}
</ul>
</PopoverContent>
</Popover>
</>
) : null}
<Separator <Tooltip>
orientation="vertical" <TooltipTrigger asChild>
className="mx-0.5 !h-5 hidden md:block" <span>
/> <Button
variant="brand"
size="sm"
className="h-11 gap-1.5 md:h-8"
onClick={onRun}
disabled={running || !enabled}
data-testid="run-flow"
>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4" />
)}
Run
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{enabled ? "Run every node once" : "Start the flow to run it"}
</TooltipContent>
</Tooltip>
<LogsPanel flow={flow} {...logs} /> <Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"size-11 text-muted-foreground md:size-8",
paused && "text-primary",
)}
onClick={onTogglePause}
disabled={!enabled}
aria-label={paused ? "Resume flow" : "Pause flow"}
data-testid={paused ? "resume-flow" : "pause-flow"}
>
{paused ? <PlayCircle /> : <Pause />}
</Button>
</TooltipTrigger>
<TooltipContent>
{!enabled
? "This flow is stopped"
: paused
? "Let the flow carry on"
: "Hold the nodes; values still arrive"}
</TooltipContent>
</Tooltip>
{paused && enabled ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-11 text-muted-foreground md:size-8" className="size-11 text-muted-foreground md:size-8"
onClick={onStep} onClick={onEditFlow}
disabled={stepping} aria-label="Flow settings"
aria-label="Step the flow" data-testid="edit-flow"
data-testid="step-flow"
> >
<StepForward /> <Pencil />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Let one held-back item through</TooltipContent> <TooltipContent>Flow settings</TooltipContent>
</Tooltip> </Tooltip>
) : null}
<Tooltip> {/* Throwing the edit away sits next to putting it live, and only exists
<TooltipTrigger asChild>
<span>
<Button
variant="brand"
size="sm"
className="h-11 gap-1.5 md:h-8"
onClick={onRun}
disabled={running || !enabled}
data-testid="run-flow"
>
{running ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4" />
)}
Run
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{enabled ? "Run every node once" : "Start the flow to run it"}
</TooltipContent>
</Tooltip>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
{/* Throwing the edit away sits next to putting it live, and only exists
while there is something to throw away. */} while there is something to throw away. */}
{hasDraft ? ( {hasDraft ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onDiscard}
aria-label="Discard the unpublished changes"
data-testid="discard-draft"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>Discard the unpublished changes</TooltipContent>
</Tooltip>
) : null}
{/*
* Saved state and publish are one control: the glyph never moves, it
* simply stops being something you can press once there is nothing left
* to put live. A button that appears and disappears moved everything
* beside it just as the work was finished.
*/}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <span>
variant="ghost" <Button
size="icon" variant="ghost"
className="size-11 text-muted-foreground md:size-8" size="icon"
onClick={onDiscard} className="size-11 text-muted-foreground md:size-8"
aria-label="Discard the unpublished changes" onClick={onPublish}
data-testid="discard-draft" disabled={!hasDraft || publishing || saving || !connected}
> aria-label="Publish this flow"
<X /> data-testid="publish-flow"
</Button> >
{!connected ? (
<WifiOff className="size-3.5" />
) : saving || publishing ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Discard the unpublished changes</TooltipContent> <TooltipContent>
{!connected
? "Reconnecting to the engine"
: publishing
? "Publishing"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip> </Tooltip>
</motion.div>
</div>
)
}
/**
* What stops this flow running, or is merely worth knowing — the same chrome as
* the logs, so a canvas with both open shows one pair rather than a panel and a
* popover disagreeing about what a panel looks like.
*
* Not a popover for the same reason the logs are not: clicking a node is what
* you do while reading this, since each line points at one.
*/
function IssuesPanel({
open,
issues,
onClose,
onFocusNode,
}: {
open: boolean
issues: ValidationIssue[]
onClose: () => void
onFocusNode: (nodeId: string) => void
}) {
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [open, onClose])
return (
<AnimatePresence>
{open && issues.length > 0 ? (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className={PANEL}
data-testid="issues-panel"
>
<div className="border-b border-border px-3 py-2">
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Needs attention
</p>
</div>
<ul className="grid gap-0.5 p-2">
{issues.map((issue) => (
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
<button
type="button"
className={cn(
"w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent",
issue.advisory && "text-muted-foreground",
)}
onClick={() => issue.node && onFocusNode(issue.node)}
disabled={!issue.node}
>
{issue.message}
</button>
</li>
))}
</ul>
</motion.div>
) : null} ) : null}
</AnimatePresence>
{/*
* Saved state and publish are one control: the glyph never moves, it
* simply stops being something you can press once there is nothing left
* to put live. A button that appears and disappears moved everything
* beside it just as the work was finished.
*/}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onPublish}
disabled={!hasDraft || publishing || saving || !connected}
aria-label="Publish this flow"
data-testid="publish-flow"
>
{!connected ? (
<WifiOff className="size-3.5" />
) : saving || publishing ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{!connected
? "Reconnecting to the engine"
: publishing
? "Publishing"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
</motion.div>
) )
} }
+124 -105
View File
@@ -13,6 +13,13 @@ import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { liveStore, useLiveLogs } from "./liveStore" import { liveStore, useLiveLogs } from "./liveStore"
/**
* The chrome a panel over this canvas wears: frosted, bordered, and the same
* width whichever of them is open, so two side by side read as one pair.
*/
export const PANEL =
"pointer-events-auto w-[30rem] max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
function shortTime(ts: number): string { function shortTime(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString(undefined, { return new Date(ts * 1000).toLocaleTimeString(undefined, {
hour12: false, hour12: false,
@@ -36,6 +43,38 @@ export type LogsFilter = {
onClearNode: () => void onClearNode: () => void
} }
/**
* The dock button that opens the logs. Separate from the panel because the two
* sit in different rows: the trigger belongs in the bar, the panel above it,
* beside whatever else is open.
*/
export function LogsTrigger({
open,
onOpenChange,
}: Pick<LogsFilter, "open" | "onOpenChange">) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"size-11 text-muted-foreground md:size-8",
open && "text-primary",
)}
onClick={() => onOpenChange(!open)}
aria-pressed={open}
aria-label="Logs"
data-testid="flow-logs"
>
<Terminal />
</Button>
</TooltipTrigger>
<TooltipContent>What this flow printed</TooltipContent>
</Tooltip>
)
}
/** /**
* What the nodes of this flow printed, and the tracebacks of the ones that * What the nodes of this flow printed, and the tracebacks of the ones that
* failed — the detail the one-line error bubble on a node has no room for. * failed — the detail the one-line error bubble on a node has no room for.
@@ -75,112 +114,92 @@ export function LogsPanel({
}, [open, onOpenChange]) }, [open, onOpenChange])
return ( return (
<> <AnimatePresence>
<Tooltip> {open ? (
<TooltipTrigger asChild> // Laid out by the dock's panel row rather than positioned against the
<Button // bar, so it can sit beside the issues list instead of over it — and
variant="ghost" // so neither this nor the bar nests a half-pixel translate or a
size="icon" // backdrop filter inside the other, which is what Firefox was
className={cn( // rasterising the text through. Deliberately not a popover: a click on
"size-11 text-muted-foreground md:size-8", // the canvas is what you do *while* reading the logs, so only the
open && "text-primary", // button or Escape puts them away.
)} <motion.div
onClick={() => onOpenChange(!open)} variants={slideUp}
aria-pressed={open} initial="hidden"
aria-label="Logs" animate="visible"
data-testid="flow-logs" exit="exit"
> transition={transitions.emphasized}
<Terminal /> className={PANEL}
</Button> data-testid="logs-panel"
</TooltipTrigger> >
<TooltipContent>What this flow printed</TooltipContent> <div className="flex items-center justify-between border-b border-border px-3 py-2">
</Tooltip> <div className="flex min-w-0 items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
<AnimatePresence> Logs
{open ? (
// A sibling of the button but positioned against the dock, so it
// sits centred above the whole bar and clears it by `mb-3` however
// many rows the bar wrapped into. Deliberately not a popover: a
// click on the canvas is what you do *while* reading the logs, so
// only the button or Escape puts them away.
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className="absolute bottom-full left-1/2 mb-3 w-[28rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
data-testid="logs-panel"
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div className="flex min-w-0 items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Logs
</p>
{node ? (
<Button
variant="ghost"
size="sm"
className="h-6 min-w-0 gap-1 px-2 font-mono text-xs"
onClick={onClearNode}
data-testid="clear-logs-filter"
>
<span className="truncate">{node}</span>
<X className="size-3 shrink-0" />
</Button>
) : null}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => liveStore.clearLogs()}
disabled={lines.length === 0}
>
Clear
</Button>
</div>
{lines.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
{node
? `Nothing from ${node} yet.`
: "Nothing yet. Anything a node prints shows up here."}
</p> </p>
) : ( {node ? (
<ScrollArea className="h-72"> <Button
<ul className="grid gap-1.5 p-3 font-mono text-xs"> variant="ghost"
{lines.map((line, index) => ( size="sm"
<li className="h-6 min-w-0 gap-1 px-2 font-mono text-xs"
// Lines are append-only and repeat freely, so position is the onClick={onClearNode}
// only stable identity they have. data-testid="clear-logs-filter"
key={`${line.ts}-${line.node}-${index}`} >
className="grid grid-cols-[auto_1fr] gap-2" <span className="truncate">{node}</span>
<X className="size-3 shrink-0" />
</Button>
) : null}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground"
onClick={() => liveStore.clearLogs()}
disabled={lines.length === 0}
>
Clear
</Button>
</div>
{lines.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
{node
? `Nothing from ${node} yet.`
: "Nothing yet. Anything a node prints shows up here."}
</p>
) : (
<ScrollArea className="h-72">
<ul className="grid gap-1.5 p-3 font-mono text-xs">
{lines.map((line, index) => (
<li
// Lines are append-only and repeat freely, so position is the
// only stable identity they have.
key={`${line.ts}-${line.node}-${index}`}
className="grid grid-cols-[auto_1fr] gap-2"
>
<span className="text-muted-foreground">
{shortTime(line.ts)}{" "}
<span className="text-foreground/70">
{nodeLabel(line.node, flow)}
</span>
</span>
<span
className={cn(
"whitespace-pre-wrap break-words",
line.level === "error" && "text-destructive",
)}
> >
<span className="text-muted-foreground"> {line.text.replace(/\n+$/, "")}
{shortTime(line.ts)}{" "} {line.truncated ? "\n… truncated" : ""}
<span className="text-foreground/70"> </span>
{nodeLabel(line.node, flow)} </li>
</span> ))}
</span> <li ref={bottom} aria-hidden />
<span </ul>
className={cn( </ScrollArea>
"whitespace-pre-wrap break-words", )}
line.level === "error" && "text-destructive", </motion.div>
)} ) : null}
> </AnimatePresence>
{line.text.replace(/\n+$/, "")}
{line.truncated ? "\n… truncated" : ""}
</span>
</li>
))}
<li ref={bottom} aria-hidden />
</ul>
</ScrollArea>
)}
</motion.div>
) : null}
</AnimatePresence>
</>
) )
} }