From 1a589570b152248517a654bbd91d51417558b83f Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 17 Aug 2026 12:52:31 +0200 Subject: [PATCH] Brain: dots where a connection meets a neuron, inbound hollow and outbound filled Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB --- NOTEPAD.md | 2 + frontend/src/components/Flow/BrainEdge.tsx | 125 ++++++++++----------- frontend/src/components/Flow/BrainView.tsx | 4 +- frontend/src/components/Flow/flow.css | 56 +++++++-- 4 files changed, 111 insertions(+), 76 deletions(-) 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; } }