Computed flow layout, and mobile written into the design

The canvas lays itself out: a layered graph, left to right on a desktop and
top to bottom on a phone, with room reserved for the value each edge carries.
Nodes cannot be dragged and `NodeDef.position` is gone from the document —
a graph nobody can arrange is one worth keeping small, which is what keeps
flows atomic. Endpoints join the same layout, so their lanes and the
localStorage that remembered where they were dragged go too.

Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps
its width and wraps instead of running off the screen, the dashboard stacks
into one column rather than shrinking a wall panel to a fifth of its size, and
Home stops widening its grid track past the viewport. A Playwright project at
a phone's width fails the build when a screen no longer fits.

Along the way: publish is the checkmark that was already there rather than a
button that appears and disappears, with discard beside it on both the flow
and the dashboard; the brain reveals a neuron's name on the first tap; and the
port sparklines get room to breathe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
This commit is contained in:
2026-08-17 17:35:14 +02:00
co-authored by Claude Opus 5
parent e7a1466d7b
commit 39ee0e0aa5
37 changed files with 998 additions and 527 deletions
+7 -2
View File
@@ -15,6 +15,8 @@ export type BrainNodeData = {
size: number
/** Why this neuron cannot run, if validation found something. */
issue?: string | null
/** Show the name without a pointer to hover with — see `BrainView`. */
revealed?: boolean
[key: string]: unknown
}
@@ -31,7 +33,8 @@ const RING = 0.231
const GAP = 0.077
function BrainNodeComponent({ data }: NodeProps) {
const { label, kind, members, flows, size, issue } = data as BrainNodeData
const { label, kind, members, flows, size, issue, revealed } =
data as BrainNodeData
const emits = useGroupEmits(members)
const failed = useGroupError(members)
// Two faults, told apart the way the mark's two parts are: the ring is the
@@ -102,7 +105,9 @@ function BrainNodeComponent({ data }: NodeProps) {
* `--brand-secondary` measures 2.2:1 on `--card` in light, which is a
* fill colour, not a text colour.
*/}
<span className={cn("brain-label", problem && "brain-label-on")}>
<span
className={cn("brain-label", (problem || revealed) && "brain-label-on")}
>
<span className="max-w-[140px] truncate text-xs font-medium">
{label}
</span>
+25 -2
View File
@@ -20,9 +20,10 @@ import {
type SimulationNodeDatum,
} from "d3-force"
import { motion } from "motion/react"
import { useEffect, useMemo } from "react"
import { useEffect, useMemo, useState } from "react"
import type { BrainGraph } from "@/client"
import { useIsMobile } from "@/hooks/useMobile"
import { scaleIn } from "@/lib/motion"
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
import { BrainNode, type BrainNodeData } from "./BrainNode"
@@ -162,6 +163,23 @@ function BrainCanvas() {
[data],
)
// A name is revealed on hover, which a finger does not have. On a phone the
// first tap says which neuron this is and the second follows it — kept out
// of the layout memo so revealing one does not re-run the simulation.
const isMobile = useIsMobile()
const [revealed, setRevealed] = useState<string | null>(null)
const shown = useMemo(
() =>
revealed
? nodes.map((node) =>
node.id === revealed
? { ...node, data: { ...node.data, revealed: true } }
: node,
)
: nodes,
[nodes, revealed],
)
// A rebuild lays the whole graph out afresh, so the viewport someone was
// looking through no longer frames anything. Only when the set of neurons
// actually changed: a value arriving must not move the canvas.
@@ -186,7 +204,7 @@ function BrainCanvas() {
className="h-full w-full"
>
<ReactFlow
nodes={nodes}
nodes={shown}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
@@ -206,10 +224,15 @@ function BrainCanvas() {
panOnDrag={false}
preventScrolling={false}
onNodeClick={(_event, node) => {
if (isMobile && revealed !== node.id) {
setRevealed(node.id)
return
}
const [flow] = (node.data as BrainNodeData).flows
if (flow)
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
}}
onPaneClick={() => setRevealed(null)}
className="brain-flat h-full w-full"
>
{/* The same dot grid the editor's canvas uses, quieter and masked back
@@ -2,6 +2,7 @@ import { Handle, type NodeProps, Position } from "@xyflow/react"
import { LayoutDashboard, Workflow } from "lucide-react"
import { memo } from "react"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import type { EndpointNodeData } from "./endpoints"
@@ -22,15 +23,17 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
const { label, kind, detail, provides, requires } = data as EndpointNodeData
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
const messages = [...provides, ...requires]
// Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive.
const vertical = useIsMobile()
return (
<div
className={cn(
// Padding keeps the text off the connector dot, which sits on the edge.
"flex max-w-48 cursor-grab items-center gap-2 px-3 py-1",
"flex max-w-48 cursor-pointer items-center gap-2 px-3 py-1",
// Quiet at rest so the logic reads first; legible when reached for.
"text-muted-foreground/55 transition-colors",
"hover:text-foreground active:cursor-grabbing",
"hover:text-foreground",
selected && "text-foreground",
)}
title={messages.join("\n")}
@@ -41,7 +44,7 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
key={`in-${message}`}
type="target"
id={message}
position={Position.Left}
position={vertical ? Position.Top : Position.Left}
className="!border-border !bg-card"
/>
))}
@@ -50,7 +53,7 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
key={`out-${message}`}
type="source"
id={message}
position={Position.Right}
position={vertical ? Position.Bottom : Position.Right}
className="!border-border !bg-card"
/>
))}
+84 -36
View File
@@ -11,6 +11,7 @@ import {
Plus,
StepForward,
WifiOff,
X,
ZoomIn,
ZoomOut,
} from "lucide-react"
@@ -47,8 +48,11 @@ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
* affordance on this view; everything else stays quiet.
*
* Everything the flow bar used to carry is here too — whether the work is
* saved, the flow's own settings, and putting it live — so the top of the
* canvas is left to say which flow this is.
* 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
* DESIGN-GUIDELINES.md → Responsive.
*/
export function FlowDock({
flow,
@@ -68,6 +72,7 @@ export function FlowDock({
onFocusNode,
onEditFlow,
onPublish,
onDiscard,
}: {
flow: string
issues: ValidationIssue[]
@@ -87,6 +92,7 @@ export function FlowDock({
onFocusNode: (nodeId: string) => void
onEditFlow: () => void
onPublish: () => void
onDiscard: () => void
}) {
const { zoomIn, zoomOut, fitView } = useReactFlow()
const connected = useLiveConnection()
@@ -98,7 +104,10 @@ export function FlowDock({
animate="visible"
exit="exit"
transition={transitions.emphasized}
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-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))]"
// 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))]"
>
<Tooltip>
<TooltipTrigger asChild>
@@ -116,12 +125,17 @@ export function FlowDock({
<TooltipContent>Add a node (K)</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<Separator
orientation="vertical"
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. */}
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => zoomOut()}
aria-label="Zoom out"
>
@@ -132,7 +146,7 @@ export function FlowDock({
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
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"
>
@@ -144,7 +158,7 @@ export function FlowDock({
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => zoomIn()}
aria-label="Zoom in"
>
@@ -153,7 +167,10 @@ export function FlowDock({
{issues.length > 0 ? (
<>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -189,7 +206,10 @@ export function FlowDock({
</>
) : null}
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<LogsPanel flow={flow} {...logs} />
@@ -263,7 +283,10 @@ export function FlowDock({
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Tooltip>
<TooltipTrigger asChild>
@@ -281,41 +304,66 @@ export function FlowDock({
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
{/* Throwing the edit away sits next to putting it live, and only exists
while there is something to throw away. */}
{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>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{!connected ? (
<WifiOff className="size-3.5" />
) : saving ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
<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"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
: publishing
? "Publishing"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="h-11 shrink-0 rounded-full md:h-8"
onClick={onPublish}
disabled={publishing}
data-testid="publish-flow"
>
{publishing ? "Publishing…" : "Publish"}
</Button>
) : null}
</motion.div>
)
}
+167 -164
View File
@@ -39,6 +39,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import useCustomToast from "@/hooks/useCustomToast"
import { useIsMobile } from "@/hooks/useMobile"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { CanvasTitle } from "./CanvasTitle"
@@ -46,17 +47,12 @@ import { CommandPalette } from "./CommandPalette"
import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges"
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
import { EndpointNode } from "./EndpointNode"
import {
deriveEndpoints,
ENDPOINT_TYPE,
isEndpointNode,
placementsFor,
rememberPlacement,
} from "./endpoints"
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
import { FIT_VIEW, FlowDock } from "./FlowDock"
import { FlowNode, type FlowNodeData } from "./FlowNode"
import { FlowPanel } from "./FlowPanel"
import { LiveEdge } from "./LiveEdge"
import { type Direction, layoutGraph } from "./layout"
import { NodePanel } from "./NodePanel"
import "./flow.css"
import { liveStore, useFlowPaused } from "./liveStore"
@@ -119,8 +115,8 @@ const CLIPBOARD_KEY = "fluksio.nodeClipboard"
* Remember the document as it was before a change.
*
* Fields commit on every keystroke, so consecutive edits that leave the same
* nodes in place fold into the entry already on the stack. Anything carrying
* positions — a drag, a new node is a finished action and starts its own.
* nodes in place fold into the entry already on the stack. Anything that adds
* or removes a node is a finished action and starts its own.
*/
function record(
history: History,
@@ -145,11 +141,12 @@ function record(
history.future = []
}
/** Positions come from the layout, so xyflow's own state only tracks identity. */
function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] {
return definitions.map((node) => ({
id: node.id,
type: "flow",
position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0 },
position: { x: 0, y: 0 },
data: {},
}))
}
@@ -174,26 +171,6 @@ function CanvasBackground() {
)
}
/** Step a new node off any node already sitting at that spot. */
function freePosition(
nodes: NodeDef_Input[],
start: { x: number; y: number },
): { x: number; y: number } {
const position = { ...start }
// Roughly a node's footprint, so a nudged node clears the one below it.
const occupied = () =>
nodes.some(
(node) =>
Math.abs((node.position?.x ?? 0) - position.x) < 220 &&
Math.abs((node.position?.y ?? 0) - position.y) < 80,
)
while (occupied()) {
position.x += 48
position.y += 96
}
return position
}
/** A name that does not collide with the nodes already on the canvas. */
function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
const taken = new Set(existing.map((node) => node.id))
@@ -213,7 +190,7 @@ function FlowEditorInner({
const navigate = useNavigate()
const queryClient = useQueryClient()
const { showErrorToast, showSuccessToast } = useCustomToast()
const { screenToFlowPosition, fitView } = useReactFlow()
const { fitView } = useReactFlow()
const updateNodeInternals = useUpdateNodeInternals()
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
@@ -243,6 +220,9 @@ function FlowEditorInner({
const [rebind, setRebind] = useState<Rebind | null>(null)
const [renamed, setRenamed] = useState<MessageRename | null>(null)
const [flowPanelOpen, setFlowPanelOpen] = useState(false)
// Throwing an edit away is offered from the dock, so its confirmation lives
// here rather than inside the settings panel.
const [discardOpen, setDiscardOpen] = useState(false)
// The editor at full size takes the width the canvas chrome does not need.
const [editorExpanded, setEditorExpanded] = useState(false)
// The dock hosts the logs, but a failing node opens them too, at its own
@@ -278,14 +258,10 @@ function FlowEditorInner({
/** The same, for the changes that only touch the nodes. */
const commit = useCallback(
(nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => {
const placed = nodes.map((node) => {
const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id)
return canvas ? { ...node, position: canvas.position } : node
})
commitDoc({ ...latest.current, nodes: placed }, Boolean(positions))
(nodes: NodeDef_Input[]) => {
commitDoc({ ...latest.current, nodes })
},
[canvasNodes, commitDoc],
[commitDoc],
)
/**
@@ -383,8 +359,8 @@ function FlowEditorInner({
],
)
// A cheap fingerprint of the wiring: it changes when a name does, but not
// when a node merely moves.
// A cheap fingerprint of the wiring, and the only thing the layout depends
// on: what the graph looks like follows from what is wired to what.
const key = bindingsKey(definitions)
// Offer the names already in play: everything published is worth reading,
@@ -407,31 +383,6 @@ function FlowEditorInner({
}
}, [key])
// Endpoints are movable but are not the flow's to store, so where they were
// put lives in the browser rather than in flow.json.
const [moved, setMoved] = useState<Record<string, { x: number; y: number }>>(
() => placementsFor(flowName),
)
// React Flow measures a node once and keeps the size on it. Endpoints are
// rebuilt on every drag frame, so unless the measurement is carried over
// they arrive unmeasured and React Flow drops the edges attached to them
// until it has measured again — remounting those edges, which makes them
// pulse as if a value had just landed. Their own drag lit up the canvas.
const measured = useRef(new Map<string, { width: number; height: number }>())
const trackMeasured = useCallback(
(changes: NodeChange<FlowCanvasNode>[]) => {
for (const change of changes) {
if (change.type !== "dimensions" || !change.dimensions) continue
if (isEndpointNode({ id: change.id })) {
measured.current.set(change.id, change.dimensions)
}
}
onNodesChange(changes)
},
[onNodesChange],
)
/** Where clicking an endpoint takes you: the thing it stands for. */
const openEndpoint = useCallback(
(id: string) => {
@@ -451,18 +402,31 @@ function FlowEditorInner({
[navigate],
)
// React Flow measures a node once and keeps the size on it. An endpoint is
// not in `canvasNodes`, so the measurement it reports back has nowhere to
// land: without carrying it over by hand the endpoint arrives unmeasured on
// the next render, and React Flow draws an unmeasured node hidden, taking
// the edges attached to it with it.
const measured = useRef(new Map<string, { width: number; height: number }>())
const trackMeasured = useCallback(
(changes: NodeChange<FlowCanvasNode>[]) => {
for (const change of changes) {
if (change.type !== "dimensions" || !change.dimensions) continue
if (isEndpointNode({ id: change.id })) {
measured.current.set(change.id, change.dimensions)
}
}
onNodesChange(changes)
},
[onNodesChange],
)
// Dashboards and other flows wired into this one. They are drawn but never
// stored: they join at render, after everything that reads or writes
// canvasNodes, so an autosave, an undo or a delete cannot reach them.
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
// biome-ignore lint/correctness/useExhaustiveDependencies: the key covers the wiring, which is all these depend on.
const external = useMemo(() => {
const built = deriveEndpoints(
detail.endpoints ?? [],
definitions,
flowName,
new Map(canvasNodes.map((node) => [node.id, node.position])),
moved,
)
const built = deriveEndpoints(detail.endpoints ?? [], definitions, flowName)
return {
...built,
nodes: built.nodes.map((node) => {
@@ -470,30 +434,83 @@ function FlowEditorInner({
return size ? { ...node, measured: size, ...size } : node
}),
}
// biome-ignore lint/correctness/useExhaustiveDependencies: positions change on every drag frame; the key covers the wiring.
}, [detail.endpoints, key, flowName, moved])
}, [detail.endpoints, key, flowName])
// Edges follow from the name bindings, so they are derived, never stored.
// Kept off `external` deliberately: an endpoint's edges depend on which
// messages it touches, never on where it sits, so dragging one must not
// rebuild the edge array on every frame.
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every render.
const edges = useMemo(
() => [...deriveEdges(definitions, flowName), ...external.edges],
[key, flowName, detail.endpoints],
)
const shownNodes = useMemo(
() => [...renderedNodes, ...external.nodes],
[renderedNodes, external],
// 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"
/**
* Nobody places a node here — the graph lays itself out, endpoints included,
* so a producer lands upstream of what it feeds without a lane of its own.
*
* Keyed on which nodes exist and how they are wired, never on the node
* objects: React Flow writes measurements back through `onNodesChange`, so
* their identity changes constantly and the layout would run on every frame.
*/
const ids = [
...canvasNodes.map((node) => node.id),
...external.nodes.map((node) => node.id),
]
const shapeKey = `${direction}|${key}|${ids.join(",")}`
// biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render.
const positions = useMemo(
() => layoutGraph(ids, edges, direction),
[shapeKey, edges],
)
// Editing ports adds and removes handles. React Flow measures those once, so
// it has to be told, or an edge to a brand-new handle never gets drawn.
/**
* The endpoints, placed.
*
* Memoised rather than mapped at render: React Flow keeps a node's
* measurement against the object it measured, and an endpoint is not in
* `canvasNodes`, so handing over a fresh one every render would leave it
* permanently unmeasured — which React Flow draws as hidden.
*/
const externalNodes = useMemo(
() =>
external.nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
})),
[external, positions],
)
const shownNodes = useMemo(
() => [
...renderedNodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
})),
...externalNodes,
],
[renderedNodes, externalNodes, positions],
)
// Editing ports adds and removes handles, and flipping direction moves them
// to the other side. React Flow measures those once, so it has to be told,
// or an edge to a brand-new handle never gets drawn.
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes handles.
useEffect(() => {
updateNodeInternals(definitions.map((node) => node.id))
}, [key, updateNodeInternals])
updateNodeInternals([
...definitions.map((node) => node.id),
...external.nodes.map((node) => node.id),
])
}, [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.
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render.
useEffect(() => {
fitView({ ...FIT_VIEW, duration: 300 })
}, [direction, definitions.length, external.nodes.length, fitView])
const runMutation = useMutation({
mutationFn: () =>
@@ -565,18 +582,9 @@ function FlowEditorInner({
const addNode = useCallback(
(type: string, sourceRef?: string) => {
const id = uniqueNodeId(definitions, sourceRef ?? type)
// Drop it where the user is looking, but never on top of another node.
const position = freePosition(
definitions,
screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
}),
)
const node: NodeDef_Input = {
id,
type,
position,
params: {},
requires: [],
provides: [],
@@ -584,16 +592,15 @@ function FlowEditorInner({
// flow's own.
...(sourceRef ? { source_ref: sourceRef } : {}),
}
const nextDefinitions = [...definitions, node]
const nextCanvas = [
// Unwired, so the layout puts it in a rank of its own until it is bound.
setCanvasNodes([
...canvasNodes,
{ id, type: "flow", position, data: {} } as FlowCanvasNode,
]
setCanvasNodes(nextCanvas)
commit(nextDefinitions, nextCanvas)
{ id, type: "flow", position: { x: 0, y: 0 }, data: {} },
])
commit([...definitions, node])
setSelectedId(id)
},
[canvasNodes, commit, definitions, screenToFlowPosition, setCanvasNodes],
[canvasNodes, commit, definitions, setCanvasNodes],
)
const updateNode = useCallback(
@@ -819,31 +826,22 @@ function FlowEditorInner({
let pool = definitions
const pasted: NodeDef_Input[] = []
for (const node of clipboard.nodes ?? []) {
const position = freePosition(pool, {
// Offset, so a copy of a node in this flow is visibly its own.
x: (node.position?.x ?? 0) + 48,
y: (node.position?.y ?? 0) + 48,
})
const copy = { ...node, id: uniqueNodeId(pool, node.id), position }
const copy = { ...node, id: uniqueNodeId(pool, node.id) }
pool = [...pool, copy]
pasted.push(copy)
}
if (!pasted.length) return
const nextCanvas = [
setCanvasNodes([
...canvasNodes,
...pasted.map(
(node) =>
({
id: node.id,
type: "flow",
position: node.position,
data: {},
}) as FlowCanvasNode,
),
]
setCanvasNodes(nextCanvas)
commit(pool, nextCanvas)
...pasted.map((node) => ({
id: node.id,
type: "flow",
position: { x: 0, y: 0 },
data: {},
})),
])
commit(pool)
setSelectedId(pasted[pasted.length - 1].id)
clipboard.nodes.forEach((node, index) => {
@@ -883,25 +881,6 @@ function FlowEditorInner({
nodes={shownNodes}
edges={edges}
onNodesChange={trackMeasured}
onNodeDrag={(_event, _node, dragged) => {
// An endpoint's position is ours, not React Flow's, so it only
// follows the pointer if we move it every frame.
const endpoints = dragged.filter(isEndpointNode)
if (!endpoints.length) return
setMoved((current) => {
const next = { ...current }
for (const node of endpoints) next[node.id] = node.position
return next
})
}}
onNodeDragStop={(_event, _node, dragged) => {
for (const node of dragged.filter(isEndpointNode)) {
// Written once at the end; every frame would be a write per pixel.
rememberPlacement(flowName, node.id, node.position)
}
const own = dragged.filter(isDocumentNode)
if (own.length) commit(definitions, mergeDragged(canvasNodes, own))
}}
onNodesDelete={(deleted) =>
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
}
@@ -943,9 +922,14 @@ function FlowEditorInner({
proOptions={{ hideAttribution: true }}
fitView
fitViewOptions={FIT_VIEW}
minZoom={0.25}
// Low enough that the fit can always show the whole graph. A phone is
// 390px wide and a rank of several nodes is thousands, so a floor of
// 0.25 left the fit silently short and the flow running off screen.
minZoom={0.1}
maxZoom={2}
nodeDragThreshold={5}
// The graph places itself. Nothing here is arranged by hand, which is
// what keeps a flow small enough to read at a glance.
nodesDraggable={false}
connectionRadius={30}
connectOnClick
autoPanOnConnect
@@ -987,6 +971,7 @@ function FlowEditorInner({
setFlowPanelOpen(true)
}}
onPublish={() => void publishFlow()}
onDiscard={() => setDiscardOpen(true)}
logs={{
open: logsOpen,
node: logsNode,
@@ -1038,17 +1023,6 @@ function FlowEditorInner({
toggling={enableMutation.isPending}
onToggleEnabled={(next) => enableMutation.mutate(next)}
hasDraft={detail.has_draft ?? false}
discarding={discard.isPending}
onDiscardDraft={() => {
discard.mutate(undefined, {
// The published document replaces what is on the canvas, and the
// version counter goes back with it.
onSuccess: () => {
setFlowPanelOpen(false)
onReload()
},
})
}}
onClose={() => setFlowPanelOpen(false)}
/>
@@ -1164,6 +1138,42 @@ function FlowEditorInner({
</DialogContent>
</Dialog>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
The canvas goes back to the version the engine is running. What
you edited since is dropped, though the flow store's git history
keeps it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
disabled={discard.isPending}
onClick={() => {
setDiscardOpen(false)
discard.mutate(undefined, {
// The published document replaces what is on the canvas, and
// the version counter goes back with it.
onSuccess: () => {
setFlowPanelOpen(false)
onReload()
},
})
}}
data-testid="confirm-discard-draft"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/*
* Not dismissable: until one version wins, every further save fails, so
* there is nothing useful to go back to.
@@ -1203,17 +1213,10 @@ function FlowEditorInner({
)
}
function mergeDragged(
nodes: FlowCanvasNode[],
dragged: FlowCanvasNode[],
): FlowCanvasNode[] {
const moved = new Map(dragged.map((node) => [node.id, node.position]))
return nodes.map((node) =>
moved.has(node.id) ? { ...node, position: moved.get(node.id)! } : node,
)
}
/** Seed xyflow's own node state once; it owns positions while you drag. */
/**
* Seed xyflow's own node state once. It tracks which nodes exist and which are
* selected; the positions on it are placeholders the layout replaces at render.
*/
function useUnpositionedNodes(definitions: NodeDef_Input[]) {
return useNodesState<FlowCanvasNode>(toCanvasNodes(definitions))
}
+11 -4
View File
@@ -28,6 +28,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import { portOf } from "./deriveEdges"
import { useNodeEmits, useNodeStatus } from "./liveStore"
@@ -69,7 +70,7 @@ export type FlowNodeData = {
[key: string]: unknown
}
/** Vertically distribute handles so several ports stay reachable. */
/** Spread handles along the node's edge so several ports stay reachable. */
function handleOffset(index: number, total: number): string {
if (total <= 1) return "50%"
const span = 60
@@ -85,10 +86,13 @@ function PortHandles({
type: "source" | "target"
position: Position
}) {
// The ports run across whichever edge they sit on.
const along = position === Position.Top || position === Position.Bottom
return (
<>
{specs.map((spec, index) => {
const port = portOf(spec)
const offset = handleOffset(index, specs.length)
return (
<Handle
key={`${type}-${port}`}
@@ -99,7 +103,7 @@ function PortHandles({
"!bg-card !border-muted-foreground/60",
!spec.name && "unbound",
)}
style={{ top: handleOffset(index, specs.length) }}
style={along ? { left: offset } : { top: offset }}
/>
)
})}
@@ -112,6 +116,9 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
data as FlowNodeData
const live = useNodeStatus(`${flow}.${definition.id}`)
const emits = useNodeEmits(`${flow}.${definition.id}`)
// The graph runs top to bottom on a phone, so the ports have to face that
// way too — see DESIGN-GUIDELINES.md → Responsive.
const vertical = useIsMobile()
const Icon =
NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ??
// A connector's own type cannot be in the map above, and a device is
@@ -143,7 +150,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
<PortHandles
specs={definition.requires ?? []}
type="target"
position={Position.Left}
position={vertical ? Position.Top : Position.Left}
/>
<div className="flex items-center gap-2.5">
@@ -226,7 +233,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
<PortHandles
specs={definition.provides ?? []}
type="source"
position={Position.Right}
position={vertical ? Position.Bottom : Position.Right}
/>
</div>
)
+1 -44
View File
@@ -24,8 +24,6 @@ export function FlowPanel({
onChange,
onDelete,
hasDraft,
discarding,
onDiscardDraft,
enabled,
toggling,
onToggleEnabled,
@@ -37,15 +35,12 @@ export function FlowPanel({
onChange: (next: FlowDef_Input) => void
onDelete: () => void
hasDraft: boolean
discarding: boolean
onDiscardDraft: () => void
enabled: boolean
toggling: boolean
onToggleEnabled: (next: boolean) => void
onClose: () => void
}) {
const [confirmOpen, setConfirmOpen] = useState(false)
const [discardOpen, setDiscardOpen] = useState(false)
return (
<>
@@ -110,18 +105,8 @@ export function FlowPanel({
<span className={PANEL_SECTION}>Unpublished changes</span>
<p className="text-sm text-muted-foreground">
The engine is still running the last published version of this
flow.
flow. The bar below publishes it, or throws the edit away.
</p>
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
disabled={discarding}
onClick={() => setDiscardOpen(true)}
data-testid="discard-draft"
>
{discarding ? "Discarding…" : "Discard changes"}
</Button>
</div>
) : null}
</div>
@@ -156,34 +141,6 @@ export function FlowPanel({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
The canvas goes back to the version the engine is running. What
you edited since is dropped, though the flow store's git history
keeps it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
onClick={() => {
setDiscardOpen(false)
onDiscardDraft()
}}
data-testid="confirm-discard-draft"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+3 -1
View File
@@ -256,7 +256,9 @@ function PortList({
) : null}
{specs.map((spec, index) => (
<div key={`port-${index}`} className="grid gap-1">
// The curve reads as its own thing rather than as part of the row
// above it, so it gets a little air.
<div key={`port-${index}`} className="grid gap-2">
<div className="flex items-center gap-1.5">
<MessageNameInput
value={spec.name ?? ""}
+7 -74
View File
@@ -30,69 +30,22 @@ export type EndpointNodeData = {
[key: string]: unknown
}
/** Lanes either side of the graph, so a label never lands on a node. */
const GAP_X = 120
const STACK_Y = 64
/** Roughly a node's width; only used to find the right-hand lane. */
const NODE_W = 220
export function isEndpointNode(node: { id: string }): boolean {
return node.id.startsWith("dashboard:") || node.id.startsWith("flow:")
}
/**
* Where the author dragged an endpoint to.
* Build the endpoints and wire them to the nodes they touch.
*
* Not in the flow document — an endpoint is not part of the flow, and writing
* a position for one into `flow.json` would be a lie about what it contains.
* A view preference belongs to the view, so it lives in the browser.
*/
const POSITION_KEY = "fluksio-endpoint-positions"
type Placements = Record<string, Record<string, { x: number; y: number }>>
function readPlacements(): Placements {
try {
return JSON.parse(localStorage.getItem(POSITION_KEY) ?? "{}") as Placements
} catch {
return {}
}
}
export function placementsFor(
flow: string,
): Record<string, { x: number; y: number }> {
return readPlacements()[flow] ?? {}
}
export function rememberPlacement(
flow: string,
id: string,
position: { x: number; y: number },
): void {
const all = readPlacements()
all[flow] = { ...(all[flow] ?? {}), [id]: position }
try {
localStorage.setItem(POSITION_KEY, JSON.stringify(all))
} catch {
// A full or disabled store just means positions reset; not worth failing.
}
}
/**
* Place the endpoints and wire them to the nodes they touch.
*
* Positions are computed rather than stored: an endpoint is not part of the
* flow, so there is nowhere to keep a position that would not be a lie about
* what the document contains. A producer sits left of what it feeds, a
* consumer right of what feeds it.
* They carry no position: the caller lays them out together with the flow's
* own nodes (see `layout.ts`), so an endpoint that publishes lands upstream of
* what it feeds and one that reads lands downstream of what feeds it, by the
* same rule that orders everything else.
*/
export function deriveEndpoints(
endpoints: Endpoint[],
definitions: NodeDef_Input[],
flow: string,
positions: Map<string, { x: number; y: number }>,
moved: Record<string, { x: number; y: number }> = {},
): { nodes: FlowCanvasNode[]; edges: Edge[] } {
if (endpoints.length === 0) return { nodes: [], edges: [] }
@@ -118,38 +71,18 @@ export function deriveEndpoints(
}
}
// A lane either side of the graph. Anchoring each label to the node it
// feeds put them on top of the nodes, so they live outside the whole thing
// instead: producers to the left of everything, consumers to the right.
const placed = [...positions.values()]
const bounds = {
left: placed.length ? Math.min(...placed.map((p) => p.x)) : 0,
right: placed.length ? Math.max(...placed.map((p) => p.x)) + NODE_W : 0,
top: placed.length ? Math.min(...placed.map((p) => p.y)) : 0,
}
const nodes: FlowCanvasNode[] = []
const edges: Edge[] = []
// How many labels already sit on each side, so they stack instead of overlap.
const stacked = { left: 0, right: 0 }
for (const endpoint of endpoints) {
const produces = endpoint.provides ?? []
const reads = endpoint.requires ?? []
// A producer belongs upstream of what it feeds; everything else downstream.
const side = produces.length > 0 ? "left" : "right"
const index = stacked[side]
stacked[side] += 1
nodes.push({
id: endpoint.id,
type: ENDPOINT_TYPE,
position: moved[endpoint.id] ?? {
x: side === "left" ? bounds.left - GAP_X : bounds.right + GAP_X,
y: bounds.top + index * STACK_Y,
},
// Movable, so a canvas can be arranged; still not the flow's to delete.
draggable: true,
// Filled in by the layout, along with the flow's own nodes.
position: { x: 0, y: 0 },
selectable: true,
deletable: false,
data: {
+80
View File
@@ -0,0 +1,80 @@
import dagre from "@dagrejs/dagre"
/**
* Where the nodes of a flow go.
*
* Nothing on this canvas is placed by hand: a flow is a graph the editor draws,
* not a picture someone arranges. That is the design decision — a canvas nobody
* can rearrange is one worth keeping small, which is what "atomic flow" means
* here — and it also means a flow document carries no positions to go stale.
*
* Left to right on a desktop, top to bottom on a phone, which is the direction
* each screen has room to grow in.
*/
export type Direction = "LR" | "TB"
/** `FlowNode` is `min-w-[168px] max-w-[220px]`; an endpoint is narrower. */
const NODE_W = 220
/** Icon row plus two text lines, as measured. */
const NODE_H = 56
/**
* Room for the live value an edge carries (`LiveEdge`'s chip is
* `max-w-[140px]`). Reserved on the edge itself, so dagre routes nodes around
* the chip rather than through it.
*/
const LABEL_W = 150
const LABEL_H = 24
/**
* Lay the graph out and return each node's top-left corner.
*
* ponytail: every node is treated as 220×56 rather than measured. Measuring
* would feed the result back into the layout and oscillate; if nodes ever grow
* past that box, take the sizes from `node.measured` once they have settled.
*/
export function layoutGraph(
ids: string[],
edges: { source: string; target: string }[],
direction: Direction,
): Map<string, { x: number; y: number }> {
const graph = new dagre.graphlib.Graph()
graph.setDefaultEdgeLabel(() => ({}))
graph.setGraph({
rankdir: direction,
// Along the rank, and between ranks. A left-to-right graph needs the wider
// gap between ranks because the nodes themselves are wide.
nodesep: 40,
ranksep: direction === "LR" ? 110 : 80,
marginx: 40,
marginy: 40,
})
// Insertion order is what makes the result deterministic, so it follows the
// document rather than whatever order the edges happen to mention nodes in.
for (const id of ids) {
graph.setNode(id, { width: NODE_W, height: NODE_H })
}
for (const edge of edges) {
if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue
graph.setEdge(edge.source, edge.target, {
width: LABEL_W,
height: LABEL_H,
labelpos: "c",
})
}
dagre.layout(graph)
// dagre places centres; React Flow wants top-left corners.
return new Map(
ids.map((id) => {
const node = graph.node(id)
return [
id,
node
? { x: node.x - NODE_W / 2, y: node.y - NODE_H / 2 }
: { x: 0, y: 0 },
]
}),
)
}