Brain: terracotta for fault, a ring set off from the fill, and a dotted ground

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
2026-08-17 15:07:30 +02:00
co-authored by Claude Opus 5
parent e19776aed1
commit 59295b1646
3 changed files with 114 additions and 37 deletions
+32 -15
View File
@@ -19,21 +19,24 @@ export type BrainNodeData = {
} }
/** /**
* Ring thickness as a share of the diameter, measured off the brand mark: a * The mark's own proportions, measured off `fluksio-light.svg`: a 1.4545 ring
* 1.4545 stroke on a 6.3 outer diameter in `fluksio-light.svg`. * 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 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. * 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) { function BrainNodeComponent({ data }: NodeProps) {
const { label, kind, members, flows, size, issue } = data as BrainNodeData const { label, kind, members, flows, size, issue } = data as BrainNodeData
const emits = useGroupEmits(members) const emits = useGroupEmits(members)
const failed = useGroupError(members) const failed = useGroupError(members)
// One ring for both kinds of trouble: a run that broke and wiring that can // Two faults, told apart the way the mark's two parts are: the ring is the
// never run are the same answer to "can I trust this neuron right now". // 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 const problem = failed || !!issue
// 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.
@@ -53,11 +56,19 @@ function BrainNodeComponent({ data }: NodeProps) {
// 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", emits === 0 && "brain-idle",
firing && "brain-hot", 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 // Border-box, so the outer rim stays at `size` and the edges keep landing
// on it however thick the ring gets. // on it however thick the ring gets. The gap between ring and disc is an
style={{ width: size, height: size, borderWidth: size * RING }} // 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}` : ""}`} title={`${label} · ${kind} · ${members.join(", ")}${issue ? ` · ${issue}` : ""}`}
> >
{/* Remounting on each emit is what restarts the animation. */} {/* 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 * 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 * 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 * 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 * status, and here it carries two of them. Two words rather than the
* node in it, which is a sentence no label under a 32px circle can hold, * reason itself: a cycle names every node in it, which is a sentence no
* so the phrase matches what Home says and the full text stays in the * label under a 32px circle can hold, so the phrase matches what Home
* tooltip. * 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.
*/} */}
<span className={cn("brain-label", problem && "brain-label-on")}> <span className={cn("brain-label", problem && "brain-label-on")}>
<span className="max-w-[140px] truncate text-xs font-medium"> <span className="max-w-[140px] truncate text-xs font-medium">
@@ -94,8 +109,10 @@ function BrainNodeComponent({ data }: NodeProps) {
{/* The label box is only as wide as the circle it hangs under, so two {/* The label box is only as wide as the circle it hangs under, so two
words break onto two lines unless told not to. */} words break onto two lines unless told not to. */}
{problem ? ( {problem ? (
<span className="whitespace-nowrap text-xs font-medium text-destructive"> <span className="whitespace-nowrap text-xs font-medium text-foreground">
{failed ? "failed" : "cannot run"} {[failed && "failed", issue && "cannot run"]
.filter(Boolean)
.join(" · ")}
</span> </span>
) : null} ) : null}
</span> </span>
+9 -1
View File
@@ -1,6 +1,8 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { import {
Background,
BackgroundVariant,
type Edge, type Edge,
type Node, type Node,
ReactFlow, ReactFlow,
@@ -209,7 +211,13 @@ function BrainCanvas() {
navigate({ to: "/flows/$flowName", params: { flowName: flow } }) navigate({ to: "/flows/$flowName", params: { flowName: flow } })
}} }}
className="brain-flat h-full w-full" 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. */}
<Background variant={BackgroundVariant.Dots} gap={24} size={1.5} />
</ReactFlow>
</motion.div> </motion.div>
) )
} }
+73 -21
View File
@@ -137,34 +137,71 @@
.react-flow.brain-flat { .react-flow.brain-flat {
--xy-background-color: transparent; --xy-background-color: transparent;
--brain-decay: 90s; --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 * The ground the mark's nodes sit on. The band has no surface of its own and no
* thick ring, wired to its neighbours. The ring thickness is a share of the * border, so a dot grid that ran to the edges would draw the rectangle the band
* diameter and lives in `BrainNode`, which is where the diameter is known. * 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 * The two parts carry the two faults, the way the mark is built: the ring is
* word under it. What the fill's lightness carries is how recently this page * the wiring, so a flow that cannot run as written turns it `--brand-secondary`;
* saw the neuron publish: brightest just after it fires, decaying to a floor it * the disc is the node, so a run that broke turns the fill the same terracotta.
* never drops below, so a graph left open sorts itself into what is busy and * Both at once still reads, since the gap keeps them apart. This is the one
* what is not. The floor stays blue enough to still read as the mark. A fresh * place terracotta means fault rather than a call to action — see the root
* page has seen nothing either way, so everything starts at `.brain-idle`, * DESIGN-GUIDELINES.md → Colour tokens.
* halfway, claiming no history it does not have. *
* 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-cell {
--brain-fill: var(--primary);
--brain-gap: var(--card);
cursor: pointer; 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); transition: border-color var(--duration-base) var(--ease-standard);
} }
.brain-cell.brain-fault {
--brain-fill: var(--brand-secondary);
}
.brain-cell.brain-idle { .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. */ /* Snapped into, decayed out of — never the other way round. */
.brain-cell.brain-hot { .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; transition: none;
} }
@@ -174,17 +211,27 @@
border-color var(--duration-base) var(--ease-standard), border-color var(--duration-base) var(--ease-standard),
background-color var(--brain-decay) linear; 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 * The pointer lights the gap rather than adding a fourth band around a circle
* can run, and the disc is already `--primary`, so recolouring either to mark * that already has three. Ring and disc both carry a status, so neither can be
* the pointer would either overwrite a status or vanish into the fill. * recoloured to mark a hover; the surface between them carries nothing.
*/ */
.brain-cell:hover, .brain-cell:hover,
.react-flow__node:focus-visible .brain-cell { .react-flow__node:focus-visible .brain-cell {
outline: 2px solid var(--ring); --brain-gap: var(--ring);
outline-offset: 2px;
} }
/* /*
@@ -215,18 +262,23 @@
* The same falloff as the neurons, on the connection instead of the disc. It * 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 * 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. * 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-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-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. */ /* Snapped into, decayed out of — never the other way round. */
.brain-wire.brain-hot { .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 { .react-flow__edge-path.brain-edge {