diff --git a/NOTEPAD.md b/NOTEPAD.md
index f1aa85a..b6a8b32 100644
--- a/NOTEPAD.md
+++ b/NOTEPAD.md
@@ -190,6 +190,8 @@ as an em dash.
- CHORE/UX: free-form params (python nodes) get no suggestions, since there is no schema to key them off.
- PERF/UI: `BrainView` runs 300 force-layout ticks synchronously inside a `useMemo`, so the graph is laid out on the render thread.
- FEAT/UI: the brain is a band on a scrolling page now, so it neither pans nor zooms — the fit keeps the whole graph in view instead. An installation with enough flows to make the labels unreadable at that fit needs a way to open the graph larger.
+- CHORE/UI: React Flow measures a node's handle bounds out of the DOM once and never again, and in the brain that one measurement falls inside the graph's `scaleIn` entrance — so every `sourceX`/`targetX` it hands an edge there is the entrance's 4% short of the centre, permanently. `BrainEdge` takes both ends from the layout instead (position + radius). Any future view that mounts a canvas inside a transform and reads node internals meets the same thing.
+- CHORE/UI: a value passing lights the brain's connection blue for the pulse, but its two end dots only follow the slower falloff, so the flash stops where the line does.
- CHORE/UI: `flow.css` drops the focus outline on every `.react-flow__node`. Brain neurons now answer `:focus-visible` with the same border colour as hover, but the flow editor's own nodes still have no visible keyboard focus.
### Infrastructure
diff --git a/frontend/src/components/Flow/BrainEdge.tsx b/frontend/src/components/Flow/BrainEdge.tsx
index 4b2c1bb..554a4b5 100644
--- a/frontend/src/components/Flow/BrainEdge.tsx
+++ b/frontend/src/components/Flow/BrainEdge.tsx
@@ -1,4 +1,4 @@
-import { BaseEdge, type EdgeProps } from "@xyflow/react"
+import { BaseEdge, type EdgeProps, useInternalNode } from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { duration } from "@/lib/motion"
@@ -8,54 +8,20 @@ import { useLatestTs } from "./liveStore"
export type BrainEdgeData = {
/** Every message this connection carries, qualified. */
messages: string[]
- /** Circle radii of the two neurons, so the line can stop at their rims. */
+ /** Circle radii of the two neurons: their centres, and where the line stops. */
sourceRadius: number
targetRadius: number
[key: string]: unknown
}
-/** Arrowhead barb length, and how far its tip stops short of the target rim. */
-const HEAD = 7
-const RIM_GAP = 3
-
/**
- * A line from rim to rim, with an arrowhead at the end it flows into.
+ * Radius of the dot that marks where the connection meets a neuron.
*
- * Both handles sit at a neuron's centre, so the raw path runs under both
- * circles: it looks off-centre wherever the circles differ in size, and it says
- * nothing about which way the value travels. The barbs are two more segments of
- * the same stroked path rather than an SVG marker, so they dim with the line
- * they belong to instead of needing a fill of their own.
+ * A flow node's port handle is 12px across on a node some four times that; a
+ * neuron is 32px at its smallest, so the same proportion lands at 8px — small
+ * enough that several connections into one small neuron stay separate dots.
*/
-function rimPath(
- sourceX: number,
- sourceY: number,
- targetX: number,
- targetY: number,
- sourceRadius: number,
- targetRadius: number,
-): string {
- const length = Math.hypot(targetX - sourceX, targetY - sourceY) || 1
- const unitX = (targetX - sourceX) / length
- const unitY = (targetY - sourceY) / length
- // Neither trim reaches past the midpoint, so two neurons closer together than
- // their radii still get a line pointing the right way rather than a backwards
- // one. The force layout keeps them apart; this only stops it looking broken.
- const from = Math.min(sourceRadius, length / 2)
- const to = Math.min(targetRadius + RIM_GAP, length - from)
- const startX = sourceX + unitX * from
- const startY = sourceY + unitY * from
- const tipX = targetX - unitX * to
- const tipY = targetY - unitY * to
- const line = `M${startX},${startY}L${tipX},${tipY}`
- if (length - from - to < HEAD) return line
-
- const backX = tipX - unitX * HEAD
- const backY = tipY - unitY * HEAD
- const spreadX = (-unitY * HEAD) / 2
- const spreadY = (unitX * HEAD) / 2
- return `${line}M${backX + spreadX},${backY + spreadY}L${tipX},${tipY}L${backX - spreadX},${backY - spreadY}`
-}
+const DOT = 4
/**
* A connection between neurons, lit by whatever passes along it.
@@ -63,32 +29,53 @@ function rimPath(
* `LiveEdge` without the value chip or the producer check: a neuron can stand
* for several nodes, so "did *this* node publish it" has no answer here, and a
* circle the size of a coin has no room for a payload anyway.
+ *
+ * It runs centre to centre, so the line trims itself back to the two rims and
+ * then further, to clear the dot that sits on each of them: a flow node's port,
+ * filled where the value leaves and hollow where it arrives. The dots belong to
+ * the edge rather than to the node because a neuron meets its connections all
+ * round its rim, not at two fixed points — and being part of the edge is also
+ * what makes them dim along with it.
*/
-function BrainEdgeComponent({
- id,
- sourceX,
- sourceY,
- targetX,
- targetY,
- data,
-}: EdgeProps) {
+function BrainEdgeComponent({ id, source, target, data }: EdgeProps) {
const { messages, sourceRadius, targetRadius } = (data ?? {
messages: [],
sourceRadius: 0,
targetRadius: 0,
}) as BrainEdgeData
+ const sourceNode = useInternalNode(source)
+ const targetNode = useInternalNode(target)
const ts = useLatestTs(messages)
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef(0)
- const path = rimPath(
- sourceX,
- sourceY,
- targetX,
- targetY,
- sourceRadius,
- targetRadius,
- )
+ // The two centres, taken from the layout rather than from the `sourceX`/
+ // `targetX` React Flow hands an edge. Those come off the handles, which it
+ // measures out of the DOM exactly once per node — and here that once falls
+ // inside the graph's entrance scale, so every one of them is stored a few
+ // percent short of the centre and never measured again. A node's position
+ // plus its radius is the same point, exactly, with nothing measured at all.
+ const sourceX = (sourceNode?.internals.positionAbsolute.x ?? 0) + sourceRadius
+ const sourceY = (sourceNode?.internals.positionAbsolute.y ?? 0) + sourceRadius
+ const targetX = (targetNode?.internals.positionAbsolute.x ?? 0) + targetRadius
+ const targetY = (targetNode?.internals.positionAbsolute.y ?? 0) + targetRadius
+ const length = Math.hypot(targetX - sourceX, targetY - sourceY) || 1
+ const unitX = (targetX - sourceX) / length
+ const unitY = (targetY - sourceY) / length
+ // Neither trim reaches past the midpoint, so two neurons closer together than
+ // their radii still get a line pointing the right way rather than a backwards
+ // one. The force layout keeps them apart; this only stops it looking broken.
+ const from = Math.min(sourceRadius, length / 2)
+ const to = Math.min(targetRadius, length - from)
+ const startX = sourceX + unitX * from
+ const startY = sourceY + unitY * from
+ const endX = targetX - unitX * to
+ const endY = targetY - unitY * to
+ // The line stops at each dot rather than running under it, so a filled dot is
+ // one flat tone instead of a disc with a darker line showing through half of
+ // it. Never longer than the gap, or a short connection would draw backwards.
+ const trim = Math.min(DOT, (length - from - to) / 2)
+ const path = `M${startX + unitX * trim},${startY + unitY * trim}L${endX - unitX * trim},${endY - unitY * trim}`
useEffect(() => {
if (!ts || ts === lastTs.current) return
@@ -99,16 +86,28 @@ function BrainEdgeComponent({
}, [ts])
return (
-
+ >
+
+
+
+
)
}
diff --git a/frontend/src/components/Flow/BrainView.tsx b/frontend/src/components/Flow/BrainView.tsx
index cc51630..8658941 100644
--- a/frontend/src/components/Flow/BrainView.tsx
+++ b/frontend/src/components/Flow/BrainView.tsx
@@ -108,8 +108,8 @@ function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
.stop()
.tick(300)
- // Both of an edge's handles sit at a neuron's centre, so the edge is what has
- // to trim itself back to the rims; the radius is only known here.
+ // An edge draws itself from the two centres outwards, so it needs the radius
+ // to find the rims — and the size that fixes it is only known here.
const radius = new Map(placed.map((node) => [node.id, node.size / 2]))
return {
diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css
index c57cf86..48487ea 100644
--- a/frontend/src/components/Flow/flow.css
+++ b/frontend/src/components/Flow/flow.css
@@ -114,8 +114,10 @@
}
/*
- * Brain graph. Both ends of a connection sit at the neuron's centre, which is
- * what `BrainEdge` measures its rim trim and its arrowhead from.
+ * Brain graph. A neuron meets its connections all round its rim, so these two
+ * handles are only there to make React Flow treat the node as wired at all —
+ * where a connection actually lands is `BrainEdge`'s own geometry, measured
+ * from the centre they sit at.
*/
.brain-handle {
opacity: 0;
@@ -213,24 +215,56 @@
visibility: visible;
}
-/* The same falloff as the neurons, on the line instead of the disc. */
+/*
+ * The same falloff as the neurons, on the connection instead of the disc. It
+ * carries the line and its two end dots alike, so the ink is a variable on the
+ * group they share rather than a stroke on each.
+ */
+.brain-wire {
+ --brain-ink: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
+}
+
+.brain-wire.brain-idle {
+ --brain-ink: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
+}
+
+/* Snapped into, decayed out of — never the other way round. */
+.brain-wire.brain-hot {
+ --brain-ink: color-mix(in srgb, var(--muted-foreground) 85%, transparent);
+}
+
.react-flow__edge-path.brain-edge {
- stroke: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
+ stroke: var(--brain-ink);
stroke-linecap: round;
stroke-linejoin: round;
}
-.react-flow__edge-path.brain-edge.brain-idle {
- stroke: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
+/*
+ * Where a connection meets a neuron: a flow node's port, sat on the rim the same
+ * way, but told apart by direction — filled at the end the value leaves, hollow
+ * at the end it arrives. The hollow one takes the surface the band sits flat on,
+ * so it reads as a hole rather than as a disc of its own.
+ */
+.brain-dot {
+ fill: var(--card);
+ stroke: var(--brain-ink);
+ stroke-width: 1.5;
}
-.react-flow__edge-path.brain-edge.brain-hot {
- stroke: color-mix(in srgb, var(--muted-foreground) 85%, transparent);
- transition: none;
+.brain-dot.brain-dot-out {
+ fill: var(--brain-ink);
}
@media (prefers-reduced-motion: no-preference) {
- .react-flow__edge-path.brain-edge {
- transition: stroke var(--brain-decay) linear;
+ .react-flow__edge-path.brain-edge,
+ .brain-dot {
+ transition:
+ stroke var(--brain-decay) linear,
+ fill var(--brain-decay) linear;
+ }
+
+ .brain-wire.brain-hot .react-flow__edge-path.brain-edge,
+ .brain-wire.brain-hot .brain-dot {
+ transition: none;
}
}