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,
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,271 +106,336 @@ 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 (
<motion.div
variants={slideUp}
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.
// 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-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,
)}
>
<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>
<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>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<motion.div
variants={slideUp}
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. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
aria-label="Fit the flow to the screen"
>
<Maximize2 />
</Button>
</TooltipTrigger>
<TooltipContent>Fit to screen</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
aria-label="Fit the flow to the screen"
>
<Maximize2 />
</Button>
</TooltipTrigger>
<TooltipContent>Fit to screen</TooltipContent>
</Tooltip>
{issues.length > 0 ? (
<>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Popover>
<PopoverTrigger asChild>
{issues.length > 0 ? (
<>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Button
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
variant="ghost"
size="sm"
className={cn(
"h-11 gap-1.5 md:h-8",
faults.length > 0
? "text-destructive"
: "text-muted-foreground",
)}
data-testid="validation-summary"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onStep}
disabled={stepping}
aria-label="Step the flow"
data-testid="step-flow"
>
<AlertCircle className="size-4" />
{issues.length}
<StepForward />
</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}
</TooltipTrigger>
<TooltipContent>Let one held-back item through</TooltipContent>
</Tooltip>
) : null}
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Tooltip>
<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>
<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>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onStep}
disabled={stepping}
aria-label="Step the flow"
data-testid="step-flow"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<StepForward />
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Let one held-back item through</TooltipContent>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<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
{/* Throwing the edit away sits next to putting it live, and only exists
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>
<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>
<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>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>
</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}
{/*
* 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>
</AnimatePresence>
)
}
+124 -105
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,112 +114,92 @@ 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.
<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."}
<AnimatePresence>
{open ? (
// 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={PANEL}
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>
) : (
<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"
{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>
) : (
<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">
{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",
)}
>
{line.text.replace(/\n+$/, "")}
{line.truncated ? "\n… truncated" : ""}
</span>
</li>
))}
<li ref={bottom} aria-hidden />
</ul>
</ScrollArea>
)}
</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>
)
}