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
+99 -38
View File
@@ -13,15 +13,11 @@ import {
WifiOff,
X,
} 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 { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import {
Tooltip,
@@ -30,7 +26,7 @@ import {
} from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { type LogsFilter, LogsPanel } from "./LogsPanel"
import { type LogsFilter, LogsPanel, LogsTrigger, PANEL } from "./LogsPanel"
import { useLiveConnection } from "./liveStore"
/**
@@ -110,12 +106,33 @@ export function FlowDock({
}) {
const { fitView } = useReactFlow()
const connected = useLiveConnection()
const [issuesOpen, setIssuesOpen] = useState(false)
// 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
// without turning the summary red, since nothing here is actually broken.
const faults = issues.filter((issue) => !issue.advisory)
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
variants={slideUp}
initial="hidden"
@@ -125,10 +142,7 @@ export function FlowDock({
// 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(
"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,
)}
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>
@@ -174,8 +188,6 @@ export function FlowDock({
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
@@ -183,37 +195,17 @@ export function FlowDock({
"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>
</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}
@@ -222,7 +214,7 @@ export function FlowDock({
className="mx-0.5 !h-5 hidden md:block"
/>
<LogsPanel flow={flow} {...logs} />
<LogsTrigger open={logs.open} onOpenChange={logs.onOpenChange} />
<Tooltip>
<TooltipTrigger asChild>
@@ -376,5 +368,74 @@ export function FlowDock({
</TooltipContent>
</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}
</AnimatePresence>
)
}
+47 -28
View File
@@ -13,6 +13,13 @@ import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
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 {
return new Date(ts * 1000).toLocaleTimeString(undefined, {
hour12: false,
@@ -36,6 +43,38 @@ export type LogsFilter = {
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
* failed — the detail the one-line error bubble on a node has no room for.
@@ -75,41 +114,22 @@ export function LogsPanel({
}, [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>
<AnimatePresence>
{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.
// Laid out by the dock's panel row rather than positioned against the
// bar, so it can sit beside the issues list instead of over it — and
// so neither this nor the bar nests a half-pixel translate or a
// backdrop filter inside the other, which is what Firefox was
// 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
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"
className={PANEL}
data-testid="logs-panel"
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
@@ -181,6 +201,5 @@ export function LogsPanel({
</motion.div>
) : null}
</AnimatePresence>
</>
)
}