Endpoint labels sweep instead of pulse: left to right on a label publishing into the flow, right to left on one reading out of it, one pulse long, off under reduced motion; snapshot values are marked prior so catching up sweeps nothing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 00:23:01 +02:00
co-authored by Claude Opus 5.5
parent 98416dc516
commit 278a480a5c
3 changed files with 117 additions and 5 deletions
+48 -4
View File
@@ -1,11 +1,12 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"
import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react"
import { memo } from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import type { EndpointNodeData } from "./endpoints"
import { handleOffset, nodeHeight } from "./layout"
import { useLiveValues } from "./liveStore"
const KIND_ICONS = {
dashboard: LayoutDashboard,
@@ -22,10 +23,21 @@ const KIND_ICONS = {
* 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) {
function EndpointNodeComponent({ id, 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]
const messages = useMemo(
() => [...provides, ...requires],
[provides, requires],
)
const values = useLiveValues(messages)
// Which way the last value went, and a count whose change remounts the sweep
// — remounting is what restarts it. See `.endpoint-sweep` in flow.css.
const [sweep, setSweep] = useState<{ dir: "in" | "out"; n: number }>()
// When each message last carried a value, as far as this label has seen.
// The first look only takes notes: mounting is not a value passing, and
// neither is a snapshot catching the page up.
const seen = useRef<Map<string, number>>(null)
// Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive.
const vertical = useIsMobile()
@@ -34,12 +46,35 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
// node's ports are, so the layout can reserve exactly this much.
const ports = Math.max(provides.length, requires.length)
useEffect(() => {
const first = seen.current === null
seen.current ??= new Map()
let dir: "in" | "out" | undefined
for (const [index, name] of messages.entries()) {
const live = values[index]
// An empty store is a reconnect on its way, not a value.
if (!live?.ts || seen.current.get(name) === live.ts) continue
seen.current.set(name, live.ts)
if (first || live.prior) continue
// Published by this label, by the rule its own edges pulse on (LiveEdge).
if (provides.includes(name) && (!live.source || live.source.id === id)) {
dir = "in"
} else if (requires.includes(name)) {
dir ??= "out"
}
}
if (!dir) return
const next = dir
setSweep((last) => ({ dir: next, n: (last?.n ?? 0) + 1 }))
}, [values, messages, provides, requires, id])
return (
<div
style={vertical ? undefined : { minHeight: nodeHeight(ports) }}
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",
// Isolated so the sweep can sit behind the text without leaving it.
"relative isolate 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",
@@ -75,6 +110,15 @@ function EndpointNodeComponent({ data, selected }: NodeProps) {
)
})}
{sweep ? (
<span
key={sweep.n}
className={cn(
"endpoint-sweep rounded-sm",
sweep.dir === "out" && "endpoint-sweep-out",
)}
/>
) : null}
<Icon className="size-4 shrink-0" />
<span className="grid min-w-0">
<span className="truncate text-sm leading-tight">{label}</span>
+63
View File
@@ -223,6 +223,69 @@
}
}
/*
* An endpoint label has no stroke to pulse and no rim to lap, and lighting all
* of it at once would not say which way the value went. So a value passing
* sweeps across it: left to right on a label publishing into the flow, right
* to left on one reading out of it, then fades back to rest. One pulse long,
* like everything else on the canvas that answers a message.
*
* Where the light has got to is registered so it can travel, outside the guard
* below for the same reason as the node's angle: it is a type, not motion.
*/
@property --endpoint-sweep {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@media (prefers-reduced-motion: no-preference) {
/*
* Behind the text rather than over it, so the label stays as legible as it
* was; the label isolates itself, so behind does not mean behind the canvas.
* A brighter leading edge is what reads as travel; the wash it leaves is
* what reads as lit.
*/
.endpoint-sweep {
--endpoint-sweep-to: to right;
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background: linear-gradient(
var(--endpoint-sweep-to),
color-mix(in srgb, var(--primary) 14%, transparent)
calc(var(--endpoint-sweep) - 40%),
color-mix(in srgb, var(--primary) 32%, transparent)
calc(var(--endpoint-sweep) - 8%),
transparent var(--endpoint-sweep)
);
animation: endpoint-sweep var(--duration-pulse) linear forwards;
}
.endpoint-sweep.endpoint-sweep-out {
--endpoint-sweep-to: to left;
}
/*
* Constant rate while it travels, as the node's arc does. It stays mounted
* until the next value remounts it, so it has to go out on its own.
*/
@keyframes endpoint-sweep {
from {
--endpoint-sweep: 0%;
}
60% {
--endpoint-sweep: 140%;
opacity: 1;
}
to {
--endpoint-sweep: 140%;
opacity: 0;
}
}
}
/* Handles are neutral: the one brand-secondary affordance here is Run. */
.react-flow__handle {
width: 12px;
+6 -1
View File
@@ -22,6 +22,11 @@ export type LiveValue = {
value: unknown
ts: number | null
source?: ValueSource
/**
* Caught up from a snapshot rather than seen arriving. It happened, but not
* while anyone was watching — the same distinction as `priorEmits`.
*/
prior?: boolean
}
export type LiveStatus = {
status: "active" | "error" | "queued" | "running" | "success"
@@ -127,7 +132,7 @@ export const liveStore = {
},
setValues(entries: Record<string, LiveValue>) {
for (const [name, value] of Object.entries(entries)) {
values.set(name, value)
values.set(name, { ...value, prior: true })
notify(`value:${name}`)
}
},