Send a node's emit pulse round the rim instead of scaling it out

The ring hung off the node itself, and `inset` on an absolute child
resolves against the padding box — so on the brain's thick status ring it
landed inside the outer edge and painted over the status the ring carries.
Both node shapes now hang it off a border-less wrapper, where the same
offsets clear the rim whatever border the node draws (measured: 4px on all
four sides, on a 1px card border and a 7px neuron ring alike).

The shape is a conic-gradient arc masked to the padding band, running one
lap in `--duration-pulse` and going out on the way past.

Note: BrainNode.tsx also carries an unrelated in-flight change from a
concurrent session (the `seen` ref gating the pulse on a real emit); the
two could not be separated in one file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:51:21 +02:00
co-authored by Claude Opus 5
parent ace980b685
commit 558e1cdf18
4 changed files with 223 additions and 163 deletions
-1
View File
@@ -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: 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: 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: 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 (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 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 - 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
+25 -7
View File
@@ -1,9 +1,9 @@
import { Handle, type NodeProps, Position } from "@xyflow/react" 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 { duration } from "@/lib/motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useGroupEmits, useGroupError } from "./liveStore" import { useGroupActive, useGroupEmits, useGroupError } from "./liveStore"
export type BrainNodeData = { export type BrainNodeData = {
label: string label: string
@@ -44,6 +44,9 @@ function BrainNodeComponent({ data }: NodeProps) {
const { label, kind, members, flows, size, issue, revealed } = const { label, kind, members, flows, size, issue, revealed } =
data as BrainNodeData data as BrainNodeData
const emits = useGroupEmits(members) 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) const failed = useGroupError(members)
// Two faults, told apart the way the mark's two parts are: the ring is the // 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 // wiring around the node, so a flow that cannot run as written colours the
@@ -52,20 +55,33 @@ function BrainNodeComponent({ data }: NodeProps) {
// Brightest right after a publish, then left to decay. Only the peak needs // 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. // holding: the floor is the resting style, so letting go is the whole decay.
const [firing, setFiring] = useState(false) 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(() => { useEffect(() => {
if (!emits) return if (emits === seen.current) return
seen.current = emits
setFiring(true) setFiring(true)
const timer = setTimeout(() => setFiring(false), duration.pulse * 1000) const timer = setTimeout(() => setFiring(false), duration.pulse * 1000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [emits]) }, [emits])
return ( return (
// 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.
<span className="relative flex rounded-full">
{/* 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 ? <span key={emits} className="node-pulse" /> : null}
<span <span
className={cn( className={cn(
"brain-cell relative flex items-center justify-center rounded-full", "brain-cell relative flex items-center justify-center rounded-full",
// Nothing seen yet this session is not the same as seen and gone quiet. // Nothing seen yet this session is not the same as seen and gone quiet.
emits === 0 && "brain-idle", !active && "brain-idle",
firing && "brain-hot", firing && "brain-hot",
failed && "brain-fault", failed && "brain-fault",
issue ? "border-brand-secondary" : "border-foreground", issue ? "border-brand-secondary" : "border-foreground",
@@ -82,8 +98,6 @@ function BrainNodeComponent({ data }: NodeProps) {
}} }}
title={`${label} · ${kind} · ${members.join(", ")}${issue ? ` · ${issue}` : ""}`} title={`${label} · ${kind} · ${members.join(", ")}${issue ? ` · ${issue}` : ""}`}
> >
{/* Remounting on each emit is what restarts the animation. */}
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
{/* Both ends sit at the centre; the edge trims itself back to the rim. */} {/* Both ends sit at the centre; the edge trims itself back to the rim. */}
<Handle <Handle
type="target" type="target"
@@ -114,7 +128,10 @@ function BrainNodeComponent({ data }: NodeProps) {
* fill colour, not a text colour. * fill colour, not a text colour.
*/} */}
<span <span
className={cn("brain-label", (problem || revealed) && "brain-label-on")} className={cn(
"brain-label",
(problem || revealed) && "brain-label-on",
)}
> >
<span className="max-w-[140px] truncate text-xs font-medium"> <span className="max-w-[140px] truncate text-xs font-medium">
{label} {label}
@@ -130,6 +147,7 @@ function BrainNodeComponent({ data }: NodeProps) {
) : null} ) : null}
</span> </span>
</span> </span>
</span>
) )
} }
+6 -3
View File
@@ -138,15 +138,17 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES] const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
return ( 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.
<div className="relative rounded-lg">
{/* Remounting on each emit is what restarts the animation. */}
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
<div <div
className={cn( className={cn(
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow", "relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
selected && "border-primary shadow-e2", selected && "border-primary shadow-e2",
)} )}
> >
{/* Remounting on each emit is what restarts the animation. */}
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
<PortHandles <PortHandles
specs={definition.requires ?? []} specs={definition.requires ?? []}
type="target" type="target"
@@ -236,6 +238,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
position={vertical ? Position.Bottom : Position.Right} position={vertical ? Position.Bottom : Position.Right}
/> />
</div> </div>
</div>
) )
} }
+52 -12
View File
@@ -99,26 +99,66 @@
} }
} }
/* A node that just published something says so, once, and settles. */ /*
@media (prefers-reduced-motion: no-preference) { * A node that just published something says so, once, and settles: an arc runs
.node-pulse { * one lap of the rim and goes out.
position: absolute; *
inset: -3px; * The angle has to be registered to be animatable at all — an unregistered
border-radius: inherit; * custom property is a token stream, and swapping one for another mid-gradient
border: 2px solid var(--primary); * would step rather than sweep. Declared outside the guard below because it is
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); * a type, not motion.
pointer-events: none; */
animation: node-pulse var(--duration-pulse) var(--ease-emphasized) forwards; @property --node-pulse-angle {
syntax: "<angle>";
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: -4px;
border-radius: inherit;
padding: 2px;
pointer-events: none;
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 { @keyframes node-pulse {
from { from {
--node-pulse-angle: 0turn;
}
75% {
opacity: 1; opacity: 1;
transform: scale(1);
} }
to { to {
--node-pulse-angle: 1turn;
opacity: 0; opacity: 0;
transform: scale(1.09);
} }
} }
} }