diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx
index d0d06dd..df6003f 100644
--- a/frontend/src/components/Flow/FlowDock.tsx
+++ b/frontend/src/components/Flow/FlowDock.tsx
@@ -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 (
-
-
-
-
-
- Add a node (⌘K)
-
+
-
+
+
+
+
+
+ Add a node (⌘K)
+
- {/* A phone pinches to zoom and the graph fits itself, so this would only
+
+
+ {/* A phone pinches to zoom and the graph fits itself, so this would only
be taking room the rest of the bar needs. */}
-
-
-
-
- Fit to screen
-
+
+
+
+
+ Fit to screen
+
- {issues.length > 0 ? (
- <>
-
-
-
+ {issues.length > 0 ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ {!enabled
+ ? "This flow is stopped"
+ : paused
+ ? "Let the flow carry on"
+ : "Hold the nodes; values still arrive"}
+
+
+
+ {paused && enabled ? (
+
+
-
-
-
- Needs attention
-
-
- {issues.map((issue) => (
-
-
-
- ))}
-
-
-
- >
- ) : null}
+
+ Let one held-back item through
+
+ ) : null}
-
+
+
+
+
+
+
+
+ {enabled ? "Run every node once" : "Start the flow to run it"}
+
+
-
+
-
-
-
-
-
- {!enabled
- ? "This flow is stopped"
- : paused
- ? "Let the flow carry on"
- : "Hold the nodes; values still arrive"}
-
-
-
- {paused && enabled ? (
- Let one held-back item through
+ Flow settings
- ) : null}
-
-
-
-
-
-
-
- {enabled ? "Run every node once" : "Start the flow to run it"}
-
-
-
-
-
-
-
-
-
- Flow settings
-
-
- {/* 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 ? (
+
+
+
+
+ Discard the unpublished changes
+
+ ) : 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.
+ */}
-
+
+
+
- Discard the unpublished changes
+
+ {!connected
+ ? "Reconnecting to the engine"
+ : publishing
+ ? "Publishing"
+ : saving
+ ? "Saving"
+ : hasDraft
+ ? "Saved — publish to put it live"
+ : "All changes saved"}
+
+
+
+ )
+}
+
+/**
+ * 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 (
+
+ {open && issues.length > 0 ? (
+
+
+
+ Needs attention
+
+
+
+ {issues.map((issue) => (
+
+
+
+ ))}
+
+
) : 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.
- */}
-
-
-
-
-
-
-
- {!connected
- ? "Reconnecting to the engine"
- : publishing
- ? "Publishing"
- : saving
- ? "Saving"
- : hasDraft
- ? "Saved — publish to put it live"
- : "All changes saved"}
-
-
-
+
)
}
diff --git a/frontend/src/components/Flow/LogsPanel.tsx b/frontend/src/components/Flow/LogsPanel.tsx
index ff51558..849e80d 100644
--- a/frontend/src/components/Flow/LogsPanel.tsx
+++ b/frontend/src/components/Flow/LogsPanel.tsx
@@ -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) {
+ return (
+
+
+
+
+ What this flow printed
+
+ )
+}
+
/**
* 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 (
- <>
-
-
-
-
- What this flow printed
-
-
-
- {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.
-
-
-
-
- Logs
-
- {node ? (
-
- ) : null}
-
-
-
-
- {lines.length === 0 ? (
-
- {node
- ? `Nothing from ${node} yet.`
- : "Nothing yet. Anything a node prints shows up here."}
+
+ {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.
+
+
+
+
+ Logs
- ) : (
-
-
- {lines.map((line, index) => (
-
+ {node}
+
+
+ ) : null}
+
+
+
+
+ {lines.length === 0 ? (
+
+ {node
+ ? `Nothing from ${node} yet.`
+ : "Nothing yet. Anything a node prints shows up here."}
+