diff --git a/frontend/src/components/Flow/CanvasTitle.tsx b/frontend/src/components/Flow/CanvasTitle.tsx
index 06322a1..d888ebc 100644
--- a/frontend/src/components/Flow/CanvasTitle.tsx
+++ b/frontend/src/components/Flow/CanvasTitle.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from "react"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { slideUp, transitions } from "@/lib/motion"
+import { cn } from "@/lib/utils"
/**
* What you are looking at, floating top-centre over a full-bleed canvas.
@@ -16,7 +17,14 @@ import { slideUp, transitions } from "@/lib/motion"
* a panel alongside others is edited with that panel's rail on screen, because
* the wall has it too and it takes room off the canvas.
*/
-export function CanvasTitle({ children }: { children: ReactNode }) {
+export function CanvasTitle({
+ children,
+ className,
+}: {
+ children: ReactNode
+ /** Lets the shell move the bar out of the way. */
+ className?: string
+}) {
return (
{/* The sidebar carries its own collapse control; a phone has no sidebar
on screen to carry it. */}
diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx
index f352dad..c8751bd 100644
--- a/frontend/src/components/Flow/FlowDock.tsx
+++ b/frontend/src/components/Flow/FlowDock.tsx
@@ -12,8 +12,6 @@ import {
StepForward,
WifiOff,
X,
- ZoomIn,
- ZoomOut,
} from "lucide-react"
import { motion } from "motion/react"
@@ -43,6 +41,20 @@ import { useLiveConnection } from "./liveStore"
*/
export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
+/**
+ * The same fit with the settings panel's lane held clear, so an opening panel
+ * never lands on the node you are looking at. 432px is the lane the chrome is
+ * pushed out of by `md:right-[27rem]`; xyflow's padding parser takes px and %,
+ * so it cannot be spelled in rem. 10% per side is what `padding: 0.25` above
+ * resolves to, so the two fits are equally generous.
+ */
+export const FIT_VIEW_PANEL = {
+ maxZoom: FIT_VIEW.maxZoom,
+ // `as const` because xyflow types each side as `${number}px | ${number}%`,
+ // which a widened `string` does not satisfy.
+ padding: { top: "10%", bottom: "10%", left: "10%", right: "432px" } as const,
+}
+
/**
* The action bar, floating bottom-centre. Run is the one brand-secondary
* affordance on this view; everything else stays quiet.
@@ -51,7 +63,7 @@ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
* saved, the flow's own settings, and putting it live or throwing it away —
* so the top of the canvas is left to say which flow this is.
*
- * It wraps rather than overflows, and drops the zoom controls on a phone; see
+ * It wraps rather than overflows, and drops the fit control on a phone; see
* DESIGN-GUIDELINES.md → Responsive.
*/
export function FlowDock({
@@ -73,6 +85,7 @@ export function FlowDock({
onEditFlow,
onPublish,
onDiscard,
+ className,
}: {
flow: string
issues: ValidationIssue[]
@@ -93,8 +106,9 @@ export function FlowDock({
onEditFlow: () => void
onPublish: () => void
onDiscard: () => void
+ className?: string
}) {
- const { zoomIn, zoomOut, fitView } = useReactFlow()
+ const { fitView } = useReactFlow()
const connected = useLiveConnection()
return (
@@ -107,7 +121,10 @@ 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="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={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,
+ )}
>
@@ -130,17 +147,8 @@ export function FlowDock({
className="mx-0.5 !h-5 hidden md:block"
/>
- {/* A phone pinches to zoom and the graph fits itself, so these three
- would only be taking room the rest of the bar needs. */}
-
+ {/* A phone pinches to zoom and the graph fits itself, so this would only
+ be taking room the rest of the bar needs. */}
-
{issues.length > 0 ? (
<>
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx
index f2c8e2d..be3c236 100644
--- a/frontend/src/components/Flow/FlowEditor.tsx
+++ b/frontend/src/components/Flow/FlowEditor.tsx
@@ -57,7 +57,7 @@ import {
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { EndpointNode } from "./EndpointNode"
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
-import { FIT_VIEW, FlowDock } from "./FlowDock"
+import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { LiveEdge } from "./LiveEdge"
@@ -352,7 +352,7 @@ function FlowEditorInner({
const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? []
return {
...node,
- selected: node.id === selectedId,
+ selected: node.selected || node.id === selectedId,
data: {
definition: definition ?? { id: node.id },
flow: flowName,
@@ -484,9 +484,17 @@ function FlowEditorInner({
[key, flowName, detail.endpoints],
)
+ const isMobile = useIsMobile()
// Which way the graph runs. A phone has height to spare and no width, so it
// reads top to bottom; everything else reads left to right.
- const direction: Direction = useIsMobile() ? "TB" : "LR"
+ const direction: Direction = isMobile ? "TB" : "LR"
+
+ // Expanding is a desktop affordance, so a window narrowed past `md` gives the
+ // room back: the sheet it becomes has no second column to hold, and its body
+ // only scrolls while the editor is its normal size.
+ useEffect(() => {
+ if (isMobile) setEditorExpanded(false)
+ }, [isMobile])
/**
* Nobody places a node here — the graph lays itself out, endpoints included,
@@ -554,21 +562,53 @@ function FlowEditorInner({
])
}, [key, direction, external, updateNodeInternals])
- // A relayout can put a new node outside the viewport, and turning the graph
- // on its side moves everything. Both want the whole flow back in view.
+ const selected = definitions.find((node) => node.id === selectedId) ?? null
+ // A panel is the view you are working in, so it takes the room — but never
+ // the lanes the bars sit in: publishing is most wanted right after editing.
+ const panelOpen = Boolean(selected) || flowPanelOpen
+
+ // One effect owns the viewport, so nothing fights over it. Selecting a node
+ // brings that node into the lane the panel leaves; every other change to the
+ // graph — new wiring, a new endpoint, a panel opening — re-fits the whole flow
+ // into the same lane. Which of the two runs is decided by what changed, not by
+ // what is true: a selection centres once, and the port edits that follow it
+ // re-fit around it, because a new edge's far end is what wants to be seen.
// The first fit is instant: an animated one travels from React Flow's
// default viewport to the content, which is the whole flow visibly sliding
// in from the corner every time one is opened. Later fits move from
// somewhere the user was already looking, so those stay animated.
const fitted = useRef(false)
- // biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render.
+ const centred = useRef(null)
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape or the panel changes, not on every render.
useEffect(() => {
+ const focus =
+ selectedId && selectedId !== centred.current ? selectedId : null
+ centred.current = selectedId
+ // A phone's sheet covers the canvas outright, and so does the expanded
+ // editor: there is no viewport to aim.
+ if (editorExpanded) return
const frame = requestAnimationFrame(() => {
- fitView(fitted.current ? { ...FIT_VIEW, duration: 300 } : FIT_VIEW)
+ const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW
+ const duration = fitted.current ? 300 : 0
+ fitView(
+ focus
+ ? { ...view, nodes: [{ id: focus }], duration }
+ : { ...view, duration },
+ )
fitted.current = true
})
return () => cancelAnimationFrame(frame)
- }, [direction, definitions.length, external.nodes.length, fitView])
+ }, [
+ direction,
+ definitions.length,
+ external.nodes.length,
+ edges.length,
+ selectedId,
+ panelOpen,
+ editorExpanded,
+ isMobile,
+ fitView,
+ ])
const runMutation = useMutation({
mutationFn: (inputs: Record = {}) =>
@@ -832,10 +872,9 @@ function FlowEditorInner({
const id = qualifiedId.startsWith(`${flowName}.`)
? qualifiedId.slice(flowName.length + 1)
: qualifiedId
- fitView({ nodes: [{ id }], duration: 300, maxZoom: 1.2 })
setSelectedId(id)
},
- [fitView, flowName],
+ [flowName],
)
/** Put the stored draft live. Publishing what is queued means saving first. */
@@ -941,11 +980,6 @@ function FlowEditorInner({
["mod+s", "mod+k"],
)
- const selected = definitions.find((node) => node.id === selectedId) ?? null
- // A panel is the view you are working in, so it takes the room — but never
- // the lanes the bars sit in: publishing is most wanted right after editing.
- const panelOpen = Boolean(selected) || flowPanelOpen
-
return (
<>
{/*
@@ -1045,13 +1079,22 @@ function FlowEditorInner({
panelOpen && !editorExpanded && "md:right-[27rem]",
)}
>
-
+
{flowDoc.title || flowName}
{
+ if (!open) return
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onOpenChange(false)
+ }
+ window.addEventListener("keydown", onKeyDown)
+ return () => window.removeEventListener("keydown", onKeyDown)
+ }, [open, onOpenChange])
+
return (
-
+ <>
-
-
-
+
What this flow printed
-
-
-
-
- Logs
-
- {node ? (
+
+ {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}
+
- ) : null}
-
-
-
+
- {lines.length === 0 ? (
-
- {node
- ? `Nothing from ${node} yet.`
- : "Nothing yet. Anything a node prints shows up here."}
-
)
}
diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx
index 1ec68ac..914b080 100644
--- a/frontend/src/components/Flow/SidePanel.tsx
+++ b/frontend/src/components/Flow/SidePanel.tsx
@@ -137,7 +137,12 @@ export function SidePanel({
-
+
{children}
@@ -187,9 +192,9 @@ export function SidePanel({
className={cn(
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
expanded
- ? // Still a floating surface, only given the room code needs —
- // and the canvas chrome keeps its lanes above and below.
- "inset-x-4 bottom-16 top-16"
+ ? // Still a floating surface, given the whole inset: the toolbar
+ // and the flow-name box translate off screen while it is open.
+ "inset-4"
: "inset-y-4 right-4 w-[400px]",
)}
>
diff --git a/frontend/tests/runtime.spec.ts b/frontend/tests/runtime.spec.ts
index 811f320..66ebbba 100644
--- a/frontend/tests/runtime.spec.ts
+++ b/frontend/tests/runtime.spec.ts
@@ -92,9 +92,29 @@ test("the logs panel shows what a node printed and why one failed", async ({
await page.getByTestId("run-flow").click()
await page.getByTestId("flow-logs").click()
- const panel = page.locator('[data-slot="popover-content"]')
+ const panel = page.getByTestId("logs-panel")
await expect(panel).toContainText("sensor read 21.5 degrees")
await expect(panel).toContainText("RuntimeError")
+
+ // Reading the logs is something you do *while* working on the flow, so a
+ // click on the canvas leaves them up — only the button or Escape closes it.
+ await page.locator(".react-flow__pane").click({ position: { x: 8, y: 8 } })
+ await expect(panel).toBeVisible()
+
+ // It sits above the dock rather than on it, and shares its centre. The dock
+ // has no box of its own to measure, so its span is its first and last
+ // buttons; its padding is symmetric, so their midpoint is its centre.
+ const logs = (await panel.boundingBox())!
+ const dock = (await page.getByTestId("run-flow").boundingBox())!
+ expect(logs.y + logs.height).toBeLessThan(dock.y)
+
+ const first = (await page.getByTestId("add-node").boundingBox())!
+ const last = (await page.getByTestId("publish-flow").boundingBox())!
+ const centre = (first.x + last.x + last.width) / 2
+ expect(Math.abs(logs.x + logs.width / 2 - centre)).toBeLessThan(2)
+
+ await page.getByTestId("flow-logs").click()
+ await expect(panel).toBeHidden()
})
test("a flow can be paused and let go again", async ({ page }) => {