From bff20a9fb233c328c32f61a388ee5a03e572ee96 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 00:23:01 +0200 Subject: [PATCH] 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 --- frontend/src/components/Flow/EndpointNode.tsx | 52 +++++++++++++-- frontend/src/components/Flow/flow.css | 63 +++++++++++++++++++ frontend/src/components/Flow/liveStore.ts | 7 ++- 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Flow/EndpointNode.tsx b/frontend/src/components/Flow/EndpointNode.tsx index 849d866..44d8bdb 100644 --- a/frontend/src/components/Flow/EndpointNode.tsx +++ b/frontend/src/components/Flow/EndpointNode.tsx @@ -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>(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 (
+ ) : null} {label} diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css index e151c4c..aa26557 100644 --- a/frontend/src/components/Flow/flow.css +++ b/frontend/src/components/Flow/flow.css @@ -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: ""; + 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; diff --git a/frontend/src/components/Flow/liveStore.ts b/frontend/src/components/Flow/liveStore.ts index 31bf5ce..0e3fc10 100644 --- a/frontend/src/components/Flow/liveStore.ts +++ b/frontend/src/components/Flow/liveStore.ts @@ -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) { for (const [name, value] of Object.entries(entries)) { - values.set(name, value) + values.set(name, { ...value, prior: true }) notify(`value:${name}`) } },