diff --git a/NOTEPAD.md b/NOTEPAD.md index 87befb8..5c9ce2f 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -38,7 +38,6 @@ should reopen it. - CHORE/UI: a streaming edge's dashes sit inside the `prefers-reduced-motion: no-preference` guard, so a reduced-motion user gets no streaming indicator at all. Lifting `stroke-dasharray` alone out of the guard keeps the static signal without the motion. - CHORE/UI: only node-to-node edges carry the streaming dash. `endpoints.ts` builds a dashboard endpoint's edges without the producing port's `stream` flag, so a training loss feeding a chart — the case the march was drawn for — is still a plain line. - CHORE/UI: the Home flow-activity table's name cell is `max-w-0` with nothing setting a floor, so `demo_training` reads as "demo⋯" at 1440px while the row has slack to spare. The cap is what keeps the table from widening the page; it wants a minimum beside it. -- CHORE/UI: `.node-pulse`'s `inset: -3px` resolves against the padding box, so on a thick border it lands inside the outer edge and paints over the status ring rather than sitting one ring outward as its comment claims. Measured on a 9px border — the comment and the geometry disagree. - FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s) - FEAT/UI make the header (Fluksio - YEAR) and the logo in the sidebar link to the main page (fluksio.com) - FEAT/UI consider adding a diagram to the Home view which shows a histogram of the different classes of nodes and which time it takes to execute (logarithmic scale); this should give a hint on the load and help to detect bottle necks/hotspots diff --git a/frontend/src/components/Flow/BrainNode.tsx b/frontend/src/components/Flow/BrainNode.tsx index f9d5d5d..1441228 100644 --- a/frontend/src/components/Flow/BrainNode.tsx +++ b/frontend/src/components/Flow/BrainNode.tsx @@ -1,9 +1,9 @@ import { Handle, type NodeProps, Position } from "@xyflow/react" -import { memo, useEffect, useState } from "react" +import { memo, useEffect, useRef, useState } from "react" import { duration } from "@/lib/motion" import { cn } from "@/lib/utils" -import { useGroupEmits, useGroupError } from "./liveStore" +import { useGroupActive, useGroupEmits, useGroupError } from "./liveStore" export type BrainNodeData = { label: string @@ -44,6 +44,9 @@ function BrainNodeComponent({ data }: NodeProps) { const { label, kind, members, flows, size, issue, revealed } = data as BrainNodeData const emits = useGroupEmits(members) + // Whether it has published at all, which the snapshot answers for what + // happened before this page connected; `emits` only counts what we saw. + const active = useGroupActive(members) const failed = useGroupError(members) // Two faults, told apart the way the mark's two parts are: the ring is the // wiring around the node, so a flow that cannot run as written colours the @@ -52,82 +55,97 @@ function BrainNodeComponent({ data }: NodeProps) { // Brightest right after a publish, then left to decay. Only the peak needs // holding: the floor is the resting style, so letting go is the whole decay. const [firing, setFiring] = useState(false) + // The count outlives this component: the store is module-level, and a + // snapshot restores what the engine counted before the page even loaded. So + // the number we mount with is history, and only a change on top of it is + // something that just happened. + const seen = useRef(emits) useEffect(() => { - if (!emits) return + if (emits === seen.current) return + seen.current = emits setFiring(true) const timer = setTimeout(() => setFiring(false), duration.pulse * 1000) return () => clearTimeout(timer) }, [emits]) return ( - - {/* Remounting on each emit is what restarts the animation. */} - {emits > 0 ? : null} - {/* Both ends sit at the centre; the edge trims itself back to the rim. */} - - - - {flows.length > 1 ? flows.length : null} - - {/* - * A name under every circle is what makes the graph unreadable, so only - * the neuron under the pointer or the keyboard says what it is — except - * one with something wrong, since colour is never the only carrier of a - * status, and here it carries two of them. Two words rather than the - * reason itself: a cycle names every node in it, which is a sentence no - * label under a 32px circle can hold, so the phrase matches what Home - * says and the full text stays in the tooltip. - * - * The word is `--foreground` rather than the terracotta beside it: - * `--brand-secondary` measures 2.2:1 on `--card` in light, which is a - * fill colour, not a text colour. - */} + // The pulse ring measures itself from here rather than from the neuron, so + // the thick status ring stays clear of it — see `.node-pulse` in flow.css. + + {/* Remounting on each emit is what restarts the animation, so this is + gated on the pulse rather than on the count: a neuron mounting with a + count already in the store would otherwise play it once for free. */} + {firing ? : null} - - {label} + {/* Both ends sit at the centre; the edge trims itself back to the rim. */} + + + + {flows.length > 1 ? flows.length : null} - {/* The label box is only as wide as the circle it hangs under, so two - words break onto two lines unless told not to. */} - {problem ? ( - - {[failed && "failed", issue && "cannot run"] - .filter(Boolean) - .join(" · ")} + {/* + * A name under every circle is what makes the graph unreadable, so only + * the neuron under the pointer or the keyboard says what it is — except + * one with something wrong, since colour is never the only carrier of a + * status, and here it carries two of them. Two words rather than the + * reason itself: a cycle names every node in it, which is a sentence no + * label under a 32px circle can hold, so the phrase matches what Home + * says and the full text stays in the tooltip. + * + * The word is `--foreground` rather than the terracotta beside it: + * `--brand-secondary` measures 2.2:1 on `--card` in light, which is a + * fill colour, not a text colour. + */} + + + {label} - ) : null} + {/* The label box is only as wide as the circle it hangs under, so two + words break onto two lines unless told not to. */} + {problem ? ( + + {[failed && "failed", issue && "cannot run"] + .filter(Boolean) + .join(" · ")} + + ) : null} + ) diff --git a/frontend/src/components/Flow/FlowNode.tsx b/frontend/src/components/Flow/FlowNode.tsx index e7cdc51..cd5c99b 100644 --- a/frontend/src/components/Flow/FlowNode.tsx +++ b/frontend/src/components/Flow/FlowNode.tsx @@ -138,103 +138,106 @@ function FlowNodeComponent({ data, selected }: NodeProps) { const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES] return ( -
+ // The pulse ring measures itself from here rather than from the card, so + // a border can never land on top of it — see `.node-pulse` in flow.css. +
{/* Remounting on each emit is what restarts the animation. */} {emits > 0 ? : null} +
+ - - -
- - - - - - {definition.title || definition.id} +
+ + - - {typeLabel} + + + {definition.title || definition.id} + + + {typeLabel} + - - {status === "running" ? ( - - - - - Stop this node - - ) : null} + {status === "running" ? ( + + + + + Stop this node + + ) : null} - {status === "error" && onShowLogs ? ( - - - - - Show the traceback - - ) : null} + {status === "error" && onShowLogs ? ( + + + + + Show the traceback + + ) : null} - {style ? ( - - - - - - {problem || style.label} - - - ) : null} + {style ? ( + + + + + + {problem || style.label} + + + ) : null} +
+ +
- -
) } diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css index 4c1313b..a7720bb 100644 --- a/frontend/src/components/Flow/flow.css +++ b/frontend/src/components/Flow/flow.css @@ -99,26 +99,66 @@ } } -/* A node that just published something says so, once, and settles. */ +/* + * A node that just published something says so, once, and settles: an arc runs + * one lap of the rim and goes out. + * + * The angle has to be registered to be animatable at all — an unregistered + * custom property is a token stream, and swapping one for another mid-gradient + * would step rather than sweep. Declared outside the guard below because it is + * a type, not motion. + */ +@property --node-pulse-angle { + syntax: ""; + inherits: false; + initial-value: 0turn; +} + @media (prefers-reduced-motion: no-preference) { + /* + * Hangs off the wrapper the node's border is *not* on: `inset` on an absolute + * child resolves against the padding box, so a ring measured from the node + * itself lands inside a thick border — the brain's status ring is one — and + * paints over it. Measured from the wrapper, the same offsets clear the rim + * whatever border the node draws. The wrapper carries the radius too, which + * is what lets one rule fit both a rounded card and a circle. + * + * The band is the padding: the content box is cut back out of the mask, which + * leaves a ring rather than the filled shape the gradient paints. + */ .node-pulse { position: absolute; - inset: -3px; + inset: -4px; border-radius: inherit; - border: 2px solid var(--primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); + padding: 2px; pointer-events: none; - animation: node-pulse var(--duration-pulse) var(--ease-emphasized) forwards; + background: conic-gradient( + from var(--node-pulse-angle), + transparent, + var(--primary) 90deg, + transparent 90deg + ); + mask: + linear-gradient(#000 0 0) content-box exclude, + linear-gradient(#000 0 0); + animation: node-pulse var(--duration-pulse) linear forwards; } + /* + * One lap at a constant rate, because the arc is travelling: an ease would + * read as braking into the finish. The element stays mounted until the next + * emit remounts it, so the lap has to put itself out on the way past. + */ @keyframes node-pulse { from { + --node-pulse-angle: 0turn; + } + 75% { opacity: 1; - transform: scale(1); } to { + --node-pulse-angle: 1turn; opacity: 0; - transform: scale(1.09); } } }