From 59295b1646d6902d35361adf475bfbc3159701d1 Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 17 Aug 2026 15:07:30 +0200 Subject: [PATCH] Brain: terracotta for fault, a ring set off from the fill, and a dotted ground Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB --- frontend/src/components/Flow/BrainNode.tsx | 47 +++++++---- frontend/src/components/Flow/BrainView.tsx | 10 ++- frontend/src/components/Flow/flow.css | 94 +++++++++++++++++----- 3 files changed, 114 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/Flow/BrainNode.tsx b/frontend/src/components/Flow/BrainNode.tsx index 7de099d..4569b46 100644 --- a/frontend/src/components/Flow/BrainNode.tsx +++ b/frontend/src/components/Flow/BrainNode.tsx @@ -19,21 +19,24 @@ export type BrainNodeData = { } /** - * Ring thickness as a share of the diameter, measured off the brand mark: a - * 1.4545 stroke on a 6.3 outer diameter in `fluksio-light.svg`. + * The mark's own proportions, measured off `fluksio-light.svg`: a 1.4545 ring + * and a 0.4848 gap on a 6.303 outer diameter, leaving the disc the 38.5% in + * the middle. * - * A share rather than a fixed width because the neurons run from 32px to 96px: + * Shares rather than fixed widths because the neurons run from 32px to 96px: * the same ring in pixels would swallow the smallest and read as a hairline on * the largest, and it is the proportion that makes a circle look like the mark. */ -const RING = 0.23 +const RING = 0.231 +const GAP = 0.077 function BrainNodeComponent({ data }: NodeProps) { const { label, kind, members, flows, size, issue } = data as BrainNodeData const emits = useGroupEmits(members) const failed = useGroupError(members) - // One ring for both kinds of trouble: a run that broke and wiring that can - // never run are the same answer to "can I trust this neuron right now". + // 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 + // ring; the disc is the node itself, so a run that broke colours the disc. const problem = failed || !!issue // 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. @@ -53,11 +56,19 @@ function BrainNodeComponent({ data }: NodeProps) { // Nothing seen yet this session is not the same as seen and gone quiet. emits === 0 && "brain-idle", firing && "brain-hot", - problem ? "border-destructive" : "border-foreground", + failed && "brain-fault", + issue ? "border-brand-secondary" : "border-foreground", )} // Border-box, so the outer rim stays at `size` and the edges keep landing - // on it however thick the ring gets. - style={{ width: size, height: size, borderWidth: size * RING }} + // on it however thick the ring gets. The gap between ring and disc is an + // inset shadow rather than padding: it paints over the fill without + // taking part in the layout, so the count stays centred in the disc. + style={{ + width: size, + height: size, + borderWidth: size * RING, + boxShadow: `inset 0 0 0 ${size * GAP}px var(--brain-gap)`, + }} title={`${label} · ${kind} · ${members.join(", ")}${issue ? ` · ${issue}` : ""}`} > {/* Remounting on each emit is what restarts the animation. */} @@ -82,10 +93,14 @@ function BrainNodeComponent({ data }: NodeProps) { * 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. 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. + * 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. */} @@ -94,8 +109,10 @@ function BrainNodeComponent({ data }: NodeProps) { {/* 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" : "cannot run"} + + {[failed && "failed", issue && "cannot run"] + .filter(Boolean) + .join(" · ")} ) : null} diff --git a/frontend/src/components/Flow/BrainView.tsx b/frontend/src/components/Flow/BrainView.tsx index d256c56..9081dba 100644 --- a/frontend/src/components/Flow/BrainView.tsx +++ b/frontend/src/components/Flow/BrainView.tsx @@ -1,6 +1,8 @@ import { useQuery } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { + Background, + BackgroundVariant, type Edge, type Node, ReactFlow, @@ -209,7 +211,13 @@ function BrainCanvas() { navigate({ to: "/flows/$flowName", params: { flowName: flow } }) }} className="brain-flat h-full w-full" - /> + > + {/* The same dot grid the editor's canvas uses, quieter and masked back + to nothing at the edges — see flow.css. No zoom compensation here: + this canvas only ever sits at whatever `fitView` chose and cannot + be zoomed, so there is no moiré to chase. */} + + ) } diff --git a/frontend/src/components/Flow/flow.css b/frontend/src/components/Flow/flow.css index 148bc59..283ff44 100644 --- a/frontend/src/components/Flow/flow.css +++ b/frontend/src/components/Flow/flow.css @@ -137,34 +137,71 @@ .react-flow.brain-flat { --xy-background-color: transparent; --brain-decay: 90s; + /* Thicker than the editor's, to match the weight the mark draws them at. */ + --xy-edge-stroke-width: 2; + --xy-background-pattern-dots-color: color-mix( + in srgb, + var(--muted-foreground) 22%, + transparent + ); } /* - * A neuron is the node of the brand mark: a disc of the identity blue inside a - * thick ring, wired to its neighbours. The ring thickness is a share of the - * diameter and lives in `BrainNode`, which is where the diameter is known. + * The ground the mark's nodes sit on. The band has no surface of its own and no + * border, so a dot grid that ran to the edges would draw the rectangle the band + * otherwise does not have. An elliptical mask fades it out well before any + * edge — the same shape the layout settles into, so the dots run out roughly + * where the graph does. The stops are the mask's alpha, not a colour: opaque + * keeps the dots, transparent drops them. + */ +.brain-flat .react-flow__background { + mask-image: radial-gradient( + ellipse 62% 62% at 50% 50%, + black 0%, + transparent 100% + ); +} + +/* + * A neuron is the node of the brand mark: a disc of the identity blue, a gap in + * the surface colour, then a thick ring. Ring and gap are shares of the + * diameter and live in `BrainNode`, which is where the diameter is known; the + * gap is what the mark's tile shows between the two, which here is the card the + * band sits flat on. * - * The fill is identity and carries no status — that is the ring's job, and the - * word under it. What the fill's lightness carries is how recently this page - * saw the neuron publish: brightest just after it fires, decaying to a floor it - * never drops below, so a graph left open sorts itself into what is busy and - * what is not. The floor stays blue enough to still read as the mark. A fresh - * page has seen nothing either way, so everything starts at `.brain-idle`, - * halfway, claiming no history it does not have. + * The two parts carry the two faults, the way the mark is built: the ring is + * the wiring, so a flow that cannot run as written turns it `--brand-secondary`; + * the disc is the node, so a run that broke turns the fill the same terracotta. + * Both at once still reads, since the gap keeps them apart. This is the one + * place terracotta means fault rather than a call to action — see the root + * DESIGN-GUIDELINES.md → Colour tokens. + * + * What the fill's lightness carries is how recently this page saw the neuron + * publish: brightest just after it fires, decaying to a floor it never drops + * below, so a graph left open sorts itself into what is busy and what is not. + * The floor stays saturated enough to still read as the mark. A fresh page has + * seen nothing either way, so everything starts at `.brain-idle`, halfway, + * claiming no history it does not have. */ .brain-cell { + --brain-fill: var(--primary); + --brain-gap: var(--card); cursor: pointer; - background-color: color-mix(in srgb, var(--primary) 50%, transparent); + background-color: color-mix(in srgb, var(--brain-fill) 50%, transparent); transition: border-color var(--duration-base) var(--ease-standard); } +.brain-cell.brain-fault { + --brain-fill: var(--brand-secondary); +} + .brain-cell.brain-idle { - background-color: color-mix(in srgb, var(--primary) 65%, transparent); + background-color: color-mix(in srgb, var(--brain-fill) 65%, transparent); } /* Snapped into, decayed out of — never the other way round. */ .brain-cell.brain-hot { - background-color: color-mix(in srgb, var(--primary) 90%, transparent); + background-color: color-mix(in srgb, var(--brain-fill) 90%, transparent); transition: none; } @@ -174,17 +211,27 @@ border-color var(--duration-base) var(--ease-standard), background-color var(--brain-decay) linear; } + + /* + * A fault is a state change, not a falloff. Sharing the decay's transition + * would have the disc creep from blue to terracotta over a minute and a half, + * so the fill takes the same 200ms the ring beside it does. + */ + .brain-cell.brain-fault { + transition: + border-color var(--duration-base) var(--ease-standard), + background-color var(--duration-base) var(--ease-standard); + } } /* - * Outside the ring rather than in it: the ring is what says whether this neuron - * can run, and the disc is already `--primary`, so recolouring either to mark - * the pointer would either overwrite a status or vanish into the fill. + * The pointer lights the gap rather than adding a fourth band around a circle + * that already has three. Ring and disc both carry a status, so neither can be + * recoloured to mark a hover; the surface between them carries nothing. */ .brain-cell:hover, .react-flow__node:focus-visible .brain-cell { - outline: 2px solid var(--ring); - outline-offset: 2px; + --brain-gap: var(--ring); } /* @@ -215,18 +262,23 @@ * 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. + * + * Drawn from `--foreground`, the same token as the rings: in the mark a + * connection is the same stroke as the ring it runs into, and mixing the two + * families instead left the graph looking like rims in one drawing and wires + * from another. */ .brain-wire { - --brain-ink: color-mix(in srgb, var(--muted-foreground) 38%, transparent); + --brain-ink: color-mix(in srgb, var(--foreground) 32%, transparent); } .brain-wire.brain-idle { - --brain-ink: color-mix(in srgb, var(--muted-foreground) 55%, transparent); + --brain-ink: color-mix(in srgb, var(--foreground) 48%, 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); + --brain-ink: color-mix(in srgb, var(--foreground) 80%, transparent); } .react-flow__edge-path.brain-edge {