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:
@@ -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,12 +106,33 @@ 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 (
|
||||||
|
// The panels and the bar are siblings in one column, so neither nests its
|
||||||
|
// half-pixel centring translate or its backdrop filter inside the other —
|
||||||
|
// which is what Firefox was drawing the console's text through. It also
|
||||||
|
// gives the two panels a row to share, instead of one landing on the other.
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 flex-col items-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex max-w-[calc(100vw-2rem)] flex-wrap items-end justify-center gap-3">
|
||||||
|
<IssuesPanel
|
||||||
|
open={issuesOpen}
|
||||||
|
issues={issues}
|
||||||
|
onClose={() => setIssuesOpen(false)}
|
||||||
|
onFocusNode={onFocusNode}
|
||||||
|
/>
|
||||||
|
<LogsPanel flow={flow} {...logs} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={slideUp}
|
variants={slideUp}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -125,10 +142,7 @@ export function FlowDock({
|
|||||||
// Capped and wrapping: the canvas shell clips, so an uncapped row would
|
// 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
|
// put the buttons at its ends out of reach on a phone rather than merely
|
||||||
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
|
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
|
||||||
className={cn(
|
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))]"
|
||||||
"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))]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -174,8 +188,6 @@ export function FlowDock({
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
className="mx-0.5 !h-5 hidden md:block"
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
/>
|
/>
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -183,37 +195,17 @@ export function FlowDock({
|
|||||||
"h-11 gap-1.5 md:h-8",
|
"h-11 gap-1.5 md:h-8",
|
||||||
faults.length > 0
|
faults.length > 0
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
|
: issuesOpen
|
||||||
|
? "text-primary"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
|
onClick={() => setIssuesOpen(!issuesOpen)}
|
||||||
|
aria-pressed={issuesOpen}
|
||||||
data-testid="validation-summary"
|
data-testid="validation-summary"
|
||||||
>
|
>
|
||||||
<AlertCircle className="size-4" />
|
<AlertCircle className="size-4" />
|
||||||
{issues.length}
|
{issues.length}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="w-80 p-2">
|
|
||||||
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
|
||||||
Needs attention
|
|
||||||
</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}
|
) : null}
|
||||||
|
|
||||||
@@ -222,7 +214,7 @@ export function FlowDock({
|
|||||||
className="mx-0.5 !h-5 hidden md:block"
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LogsPanel flow={flow} {...logs} />
|
<LogsTrigger open={logs.open} onOpenChange={logs.onOpenChange} />
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -376,5 +368,74 @@ export function FlowDock({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</motion.div>
|
</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}
|
||||||
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,41 +114,22 @@ export function LogsPanel({
|
|||||||
}, [open, onOpenChange])
|
}, [open, onOpenChange])
|
||||||
|
|
||||||
return (
|
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>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open ? (
|
{open ? (
|
||||||
// A sibling of the button but positioned against the dock, so it
|
// Laid out by the dock's panel row rather than positioned against the
|
||||||
// sits centred above the whole bar and clears it by `mb-3` however
|
// bar, so it can sit beside the issues list instead of over it — and
|
||||||
// many rows the bar wrapped into. Deliberately not a popover: a
|
// so neither this nor the bar nests a half-pixel translate or a
|
||||||
// click on the canvas is what you do *while* reading the logs, so
|
// backdrop filter inside the other, which is what Firefox was
|
||||||
// only the button or Escape puts them away.
|
// rasterising the text through. 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
|
<motion.div
|
||||||
variants={slideUp}
|
variants={slideUp}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={transitions.emphasized}
|
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"
|
className={PANEL}
|
||||||
data-testid="logs-panel"
|
data-testid="logs-panel"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
@@ -181,6 +201,5 @@ export function LogsPanel({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user