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 1c09b8209d
commit bb90a24b90
37 changed files with 998 additions and 527 deletions
@@ -7,6 +7,7 @@ import {
Pencil,
Plus,
Settings2,
X,
} from "lucide-react"
import { motion } from "motion/react"
import { type CSSProperties, useEffect, useRef, useState } from "react"
@@ -27,6 +28,14 @@ import {
} from "@/client"
import { CanvasTitle } from "@/components/Flow/CanvasTitle"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Popover,
PopoverContent,
@@ -40,6 +49,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { useIsMobile } from "@/hooks/useMobile"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
@@ -59,7 +69,12 @@ import {
widgetsOf,
} from "./DashboardView"
import { DashboardPanel, WidgetPanel } from "./panels"
import { dashboardKeys, usePublishDashboard, useSaveDashboard } from "./queries"
import {
dashboardKeys,
useDiscardDashboardDraft,
usePublishDashboard,
useSaveDashboard,
} from "./queries"
import {
WIDGET_LABELS,
WIDGET_SIZES,
@@ -167,7 +182,10 @@ const same = (a: Layout, b: Layout) =>
* A dashboard, viewed or edited, over the same dotted canvas the flows use.
*
* View mode never mounts the grid library: a wall panel that only displays
* should not pay for the code that lets someone drag things around.
* should not pay for the code that lets someone drag things around. Nor does a
* phone — arranging is not a phone feature, and `applyLayout` below writes
* whatever the grid reports, so a stacked layout reaching it would overwrite
* the arrangement the panel is meant to show.
*/
export function DashboardEditor({
dashboard,
@@ -182,10 +200,15 @@ export function DashboardEditor({
const [pageId, setPageId] = useState<string | undefined>(
() => pagesOf(dashboard)[0]?.id,
)
// A phone reads the dashboard rather than arranges it, so the grid library
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
const stacked = useIsMobile()
const navigate = useNavigate()
const queryClient = useQueryClient()
const save = useSaveDashboard(dashboard.name)
const publish = usePublishDashboard(dashboard.name)
const discard = useDiscardDashboardDraft(dashboard.name)
const [discardOpen, setDiscardOpen] = useState(false)
const { showErrorToast } = useCustomToast()
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// The save that is already on its way, so a publish waits for it instead of
@@ -338,6 +361,27 @@ export function DashboardEditor({
})
}
/** A widget that can be picked to open its settings. */
const pickable = (widget: WidgetDef, grip = false) => (
<WidgetFrame
title={widget.title}
issue={widgetIssue(widget)}
className={cn(
"cursor-pointer",
widget.id === selected && "ring-2 ring-primary",
)}
grip={grip}
onClick={(event) => {
if (!(event.target as Element).closest(INTERACTIVE)) {
setSettingsOpen(false)
setSelected(widget.id)
}
}}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</WidgetFrame>
)
const body = !page ? (
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
@@ -346,6 +390,17 @@ export function DashboardEditor({
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
Nothing on this page yet. Add a widget from the bar below.
</p>
) : stacked ? (
// One column at the viewport's width. Edit mode still picks a widget and
// opens its settings; only the arrangement is missing.
<div className="h-full overflow-y-auto">
<DashboardView
dashboard={draft}
pageId={page.id}
stacked
renderWidget={edit ? pickable : undefined}
/>
</div>
) : (
<CanvasSurface dashboard={draft} dots={edit}>
{(scale) =>
@@ -372,25 +427,7 @@ export function DashboardEditor({
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
<div key={widget.id}>
<WidgetFrame
title={widget.title}
issue={widgetIssue(widget)}
className={cn(
"cursor-pointer",
widget.id === selected && "ring-2 ring-primary",
)}
grip
onClick={(event) => {
if (!(event.target as Element).closest(INTERACTIVE)) {
setSettingsOpen(false)
setSelected(widget.id)
}
}}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</WidgetFrame>
</div>
<div key={widget.id}>{pickable(widget, true)}</div>
))}
</GridLayout>
)
@@ -439,7 +476,9 @@ export function DashboardEditor({
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, like the flow dock; 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))]"
>
{edit ? (
<>
@@ -494,39 +533,66 @@ export function DashboardEditor({
<TooltipContent>Dashboard settings</TooltipContent>
</Tooltip>
{/* Only 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={() => setDiscardOpen(true)}
aria-label="Discard the unpublished changes"
data-testid="discard-dashboard"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>
Discard the unpublished changes
</TooltipContent>
</Tooltip>
) : null}
{/* Saved state and publish are one control, as in the flow dock:
the glyph stays put and simply stops being pressable. */}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{save.isPending ? (
<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={() => void publishDashboard()}
disabled={
!hasDraft || publish.isPending || save.isPending
}
aria-label="Publish this dashboard"
data-testid="publish-dashboard"
>
{save.isPending || publish.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{save.isPending
? "Saving"
: hasDraft
? "Saved — publish to put it on the panels"
: "All changes saved"}
{publish.isPending
? "Publishing"
: save.isPending
? "Saving"
: hasDraft
? "Saved — publish to put it on the panels"
: "All changes saved"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="h-11 shrink-0 rounded-full md:h-8"
onClick={() => void publishDashboard()}
disabled={publish.isPending}
data-testid="publish-dashboard"
>
{publish.isPending ? "Publishing…" : "Publish"}
</Button>
) : null}
<Separator orientation="vertical" className="mx-0.5 !h-5" />
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
</>
) : null}
@@ -590,6 +656,38 @@ export function DashboardEditor({
onDelete={() => remove.mutate()}
onClose={() => setSettingsOpen(false)}
/>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
The dashboard goes back to what the panels are showing. What
you edited since is dropped.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
disabled={discard.isPending}
onClick={() => {
setDiscardOpen(false)
// Leaving edit mode remounts the editor, which is how the
// published document replaces the working copy.
discard.mutate(undefined, {
onSuccess: () => setEdit(false),
})
}}
data-testid="confirm-discard-dashboard"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
) : null}
</>
@@ -169,6 +169,7 @@ export function SectionGrid({
dashboard,
columns = DEFAULT_COLUMNS,
renderWidget,
stacked,
className,
}: {
section: SectionDef_Output
@@ -176,9 +177,20 @@ export function SectionGrid({
dashboard: string
columns?: number
renderWidget?: (widget: WidgetDef) => React.ReactNode
/** One column at the viewport's width, for a phone. */
stacked?: boolean
className?: string
}) {
const widgets = widgetsOf(section)
const all = widgetsOf(section)
// Stacked, the arrangement becomes a reading order, so it follows the rows
// the panel shows rather than the order widgets happened to be added in.
const widgets = stacked
? [...all].sort((a, b) => {
const left = placement(a)
const right = placement(b)
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
})
: all
return (
<section className="grid gap-3">
{section.title ? (
@@ -187,7 +199,7 @@ export function SectionGrid({
</h2>
) : null}
<div
className={cn("widget-grid", className)}
className={cn("widget-grid", stacked && "widget-stacked", className)}
data-placed={isPlaced(widgets) || undefined}
style={{ "--widget-cols": columns } as React.CSSProperties}
>
@@ -215,10 +227,13 @@ export function DashboardView({
dashboard,
pageId,
renderWidget,
stacked,
}: {
dashboard: Dashboard
pageId?: string
renderWidget?: (widget: WidgetDef) => React.ReactNode
/** One column at the viewport's width, for a phone. */
stacked?: boolean
}) {
const pages = pagesOf(dashboard)
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
@@ -254,6 +269,7 @@ export function DashboardView({
dashboard={dashboard.name}
columns={columnsOf(dashboard)}
renderWidget={renderWidget}
stacked={stacked}
/>
))}
</div>
@@ -22,9 +22,10 @@
/*
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
* panel can be matched to its own width. There is no responsive fallback: the
* grid lives on a canvas of the panel's own pixel size, which is scaled to the
* viewport rather than reflowed into it.
* panel can be matched to its own width. It does not reflow: the grid lives on
* a canvas of the panel's own pixel size, which is scaled to the viewport.
* A phone gets `.widget-stacked` below instead, which is a different surface
* rather than a narrower version of this one.
*/
.widget-grid {
display: grid;
@@ -46,6 +47,25 @@
grid-row: var(--y) / span var(--h);
}
/*
* One column, in reading order, at the viewport's own width — what a phone
* gets instead of a wall panel shrunk to 18%. Arranging is not a phone
* feature, so the stored x/w are dropped and only the height a widget asked
* for survives: a chart still needs its room, a stat still does not.
* See DESIGN-GUIDELINES.md → Responsive.
*/
.widget-grid.widget-stacked,
.widget-grid.widget-stacked[data-placed] {
display: flex;
flex-direction: column;
}
.widget-grid.widget-stacked .widget-cell {
grid-column: auto;
grid-row: auto;
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
}
/*
* uPlot, routed through the tokens. Its own legend is the hover readout as
* well — the value each line carried at the cursor — so it is styled as chart
@@ -75,6 +75,19 @@ export function usePublishDashboard(name: string) {
})
}
/** Throw the unpublished edit away; the panels keep showing what they had. */
export function useDiscardDashboardDraft(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => DashboardsService.discardDashboardDraft({ name }),
onSuccess: (published) => {
queryClient.setQueryData(dashboardKeys.detail(name, true), published)
queryClient.setQueryData(dashboardKeys.detail(name), published)
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
},
})
}
/** What an input widget does: put a value into the graph.
*
* The widget names itself so the flow canvas can show the value arriving from
@@ -201,6 +201,7 @@ export function WidgetFrame({
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
<div
data-testid="widget-frame"
className={cn(
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
className,
+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 },
]
}),
)
}
@@ -157,7 +157,9 @@ function Failure({ event }: { event: EventRow }) {
) : (
<span className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1">
{/* A traceback's first line runs long; it wraps rather than widening
the card, and the rest is behind the chevron anyway. */}
<span className="min-w-0 flex-1 break-words">
<span className="font-mono text-sm">
{event.node || event.flow || "engine"}
</span>
@@ -289,24 +291,28 @@ export function HealthActivity({ range }: { range: Range }) {
<span className="min-w-0 flex-1 truncate font-mono">
{run.flow}
</span>
<span className="text-xs text-muted-foreground">
{/* Five fixed columns do not fit a phone. What caused a run
is the one a narrow row can do without — the status, the
duration and when it ran are why anyone reads this. */}
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
{run.source}
</span>
<span
className={
className={cn(
"shrink-0",
run.status === "error"
? "text-destructive"
: run.status === "ok"
? "text-status-success"
: "text-muted-foreground"
}
: "text-muted-foreground",
)}
>
{run.status}
</span>
<span className="w-16 text-right text-muted-foreground">
<span className="w-16 shrink-0 text-right text-muted-foreground">
{si(run.duration_ms)} ms
</span>
<span className="w-16 text-right text-xs text-muted-foreground">
<span className="w-16 shrink-0 text-right text-xs text-muted-foreground">
{ago(run.started_at)}
</span>
</div>
@@ -362,8 +368,10 @@ export function HealthActivity({ range }: { range: Range }) {
<span className="min-w-0 flex-1 truncate font-mono">
{item.node}
</span>
<span className="text-muted-foreground">{item.reason}</span>
<span className="text-xs text-muted-foreground">
<span className="min-w-0 max-w-[40%] truncate text-muted-foreground">
{item.reason}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{ago(item.ts)}
</span>
</div>
@@ -172,8 +172,10 @@ export function HealthOverview({
<th className="px-3 pb-2 text-center font-medium">Lag</th>
{/* A bounded share rather than all the slack: the columns
beside it grow with their own content, so the numbers
spread across the middle instead of huddling on the left. */}
<th className="w-1/3 min-w-32 pb-2 pl-3 text-right font-medium">
spread across the middle instead of huddling on the left.
Its 128px floor is more than a phone has to spare, and a
curve that narrow says nothing, so it goes below `sm`. */}
<th className="hidden w-1/3 pb-2 pl-3 text-right font-medium sm:table-cell sm:min-w-32">
Trend
</th>
</tr>
@@ -181,11 +183,14 @@ export function HealthOverview({
<tbody>
{(flows ?? []).map((row: FlowRollup) => (
<tr key={row.flow} className="border-t border-border">
<td className="py-2 pr-3">
<td className="max-w-0 py-2 pr-3">
{/* `max-w-0` is what lets a cell truncate at all: without
it the table sizes to the longest name and pushes the
page sideways. */}
<Link
to="/flows/$flowName"
params={{ flowName: row.flow }}
className="font-mono hover:underline"
className="block truncate font-mono hover:underline"
>
{row.flow || "—"}
</Link>
@@ -210,7 +215,7 @@ export function HealthOverview({
</td>
{/* The dot straddles the curve's right edge, so the cell
keeps a little room for the half that hangs out. */}
<td className="py-2 pr-2 pl-3 text-right">
<td className="hidden py-2 pr-2 pl-3 text-right sm:table-cell">
<Spark counts={row.spark} />
</td>
</tr>
+4 -1
View File
@@ -320,7 +320,10 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-transparent relative flex w-full flex-1 flex-col",
// `min-w-0`: a flex item's default `min-width: auto` lets one wide
// child widen the whole shell, which is how a page ends up scrolling
// sideways on a phone. See DESIGN-GUIDELINES.md → Responsive.
"bg-transparent relative flex w-full min-w-0 flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:shadow-e1 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}