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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user