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.
74 lines
2.5 KiB
TypeScript
74 lines
2.5 KiB
TypeScript
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"
|
|
|
|
const KIND_ICONS = {
|
|
dashboard: LayoutDashboard,
|
|
flow: Workflow,
|
|
} as const
|
|
|
|
/**
|
|
* A thing wired into this flow that is not a node in it.
|
|
*
|
|
* Drawn as a label rather than a card on purpose: a dashboard with twenty
|
|
* tiles would otherwise bury the logic the canvas exists to show, which is
|
|
* the same reason dashboards are their own documents. It is here to account
|
|
* for a value, not to compete with the nodes for attention.
|
|
*/
|
|
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-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",
|
|
selected && "text-foreground",
|
|
)}
|
|
title={messages.join("\n")}
|
|
>
|
|
{/* Both sides always exist so an edge can attach; only one is used. */}
|
|
{requires.map((message) => (
|
|
<Handle
|
|
key={`in-${message}`}
|
|
type="target"
|
|
id={message}
|
|
position={vertical ? Position.Top : Position.Left}
|
|
className="!border-border !bg-card"
|
|
/>
|
|
))}
|
|
{provides.map((message) => (
|
|
<Handle
|
|
key={`out-${message}`}
|
|
type="source"
|
|
id={message}
|
|
position={vertical ? Position.Bottom : Position.Right}
|
|
className="!border-border !bg-card"
|
|
/>
|
|
))}
|
|
|
|
<Icon className="size-4 shrink-0" />
|
|
<span className="grid min-w-0">
|
|
<span className="truncate text-sm leading-tight">{label}</span>
|
|
<span className="truncate text-xs tracking-wide uppercase opacity-70">
|
|
{detail}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const EndpointNode = memo(EndpointNodeComponent)
|
|
export default EndpointNode
|