--brain-decay 90s -> 150s and the wire floor 32% -> 16%, so a quiet connection dims further and takes longer doing it. The pulse ring's inset and band become custom properties (defaults unchanged for the editor's card), and the neuron passes its own ring and gap so the arc runs the full width of the gap; it moves after the disc, which otherwise paints over it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
473 lines
15 KiB
CSS
473 lines
15 KiB
CSS
/*
|
|
* React Flow, routed through the design tokens.
|
|
*
|
|
* Scoped to `.react-flow` rather than added to index.css, so the canvas needs no
|
|
* tokens of its own and follows the theme in both modes without a `colorMode`
|
|
* prop. See the root DESIGN-GUIDELINES.md → Shells.
|
|
*/
|
|
|
|
.react-flow {
|
|
--xy-background-color: var(--background);
|
|
--xy-background-pattern-color: color-mix(
|
|
in srgb,
|
|
var(--muted-foreground) 30%,
|
|
transparent
|
|
);
|
|
--xy-edge-stroke: color-mix(
|
|
in srgb,
|
|
var(--muted-foreground) 55%,
|
|
transparent
|
|
);
|
|
--xy-edge-stroke-selected: var(--primary);
|
|
--xy-edge-stroke-width: 1.5;
|
|
/*
|
|
* What a pulse widens to on its way in; the width above is where it settles
|
|
* back. Per canvas, because the brain draws its connections heavier.
|
|
*/
|
|
--edge-pulse-width: 2.5;
|
|
--xy-handle-background-color: var(--card);
|
|
--xy-handle-border-color: var(--muted-foreground);
|
|
--xy-selection-background-color: color-mix(
|
|
in srgb,
|
|
var(--primary) 8%,
|
|
transparent
|
|
);
|
|
--xy-selection-border: 1px solid var(--primary);
|
|
--xy-attribution-background-color: transparent;
|
|
}
|
|
|
|
/*
|
|
* Where a pulse lands: the opaque twin of the stroke the edge rests in, so the
|
|
* last step back is invisible. A selected edge rests in blue and so decays into
|
|
* it rather than crossing to grey and snapping back at the end.
|
|
*/
|
|
.react-flow__edge {
|
|
--edge-rest: var(--muted-foreground);
|
|
}
|
|
|
|
.react-flow__edge.selected {
|
|
--edge-rest: var(--primary);
|
|
}
|
|
|
|
/*
|
|
* The dashes are the signal, the march below is only its motion: the pattern
|
|
* is declared outside the guard so a reduced-motion reader still sees which
|
|
* edges stream.
|
|
*/
|
|
.react-flow__edge-path.edge-stream {
|
|
stroke-dasharray: 6 4;
|
|
}
|
|
|
|
/* A message arriving lights its edge, then decays back to rest. */
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
.react-flow__edge-path.edge-live {
|
|
animation: edge-pulse var(--duration-pulse) var(--ease-emphasized);
|
|
}
|
|
|
|
/*
|
|
* Both ends are plain colours. The implied `to` would be the edge's resting
|
|
* stroke, which is a `color-mix()` — and interpolating a hex against one of
|
|
* those goes through oklab and leaves the gamut on the way, which turned
|
|
* every pulse fluorescent yellow.
|
|
*/
|
|
@keyframes edge-pulse {
|
|
from {
|
|
stroke: var(--primary);
|
|
stroke-width: var(--edge-pulse-width);
|
|
}
|
|
to {
|
|
stroke: var(--edge-rest);
|
|
stroke-width: var(--xy-edge-stroke-width);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* An output that publishes repeatedly during one run reads as a march rather
|
|
* than a line. Only the dash offset moves, so the pulse above still shows
|
|
* through on the same path: it recolours and widens the dashes as they go.
|
|
* A loop rather than a one-shot, so none of the `--duration-*` tokens fit —
|
|
* the literal is one dash period per step, slow enough to read as direction.
|
|
*/
|
|
.react-flow__edge-path.edge-stream {
|
|
animation: edge-march 700ms linear infinite;
|
|
}
|
|
|
|
/* Both at once: one `animation` declaration has to carry both, or the
|
|
cascade keeps whichever rule came last and drops the other. */
|
|
.react-flow__edge-path.edge-stream.edge-live {
|
|
animation:
|
|
edge-pulse var(--duration-pulse) var(--ease-emphasized),
|
|
edge-march 700ms linear infinite;
|
|
}
|
|
|
|
@keyframes edge-march {
|
|
to {
|
|
stroke-dashoffset: -10;
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* A node that just published something says so, once, and settles: an arc runs
|
|
* one lap of the rim and goes out. The same arc, lapping, is a node still at
|
|
* work.
|
|
*
|
|
* The angle has to be registered to be animatable at all — an unregistered
|
|
* custom property is a token stream, and swapping one for another mid-gradient
|
|
* would step rather than sweep. Declared outside the guard below because it is
|
|
* a type, not motion.
|
|
*/
|
|
@property --node-pulse-angle {
|
|
syntax: "<angle>";
|
|
inherits: false;
|
|
initial-value: 0turn;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
/*
|
|
* Hangs off the wrapper the node's border is *not* on: `inset` on an absolute
|
|
* child resolves against the padding box, so a ring measured from the node
|
|
* itself lands inside a thick border — the brain's status ring is one — and
|
|
* paints over it. Measured from the wrapper, the same offsets clear the rim
|
|
* whatever border the node draws. The wrapper carries the radius too, which
|
|
* is what lets one rule fit both a rounded card and a circle.
|
|
*
|
|
* The band is the padding: the content box is cut back out of the mask, which
|
|
* leaves a ring rather than the filled shape the gradient paints. Both are
|
|
* overridable, for a node whose own bands say where the arc belongs — the
|
|
* brain's neuron runs it in the gap between its ring and its disc.
|
|
*/
|
|
.node-pulse {
|
|
position: absolute;
|
|
inset: var(--node-pulse-inset, -4px);
|
|
border-radius: inherit;
|
|
padding: var(--node-pulse-band, 2px);
|
|
pointer-events: none;
|
|
background: conic-gradient(
|
|
from var(--node-pulse-angle),
|
|
transparent,
|
|
var(--primary) 90deg,
|
|
transparent 90deg
|
|
);
|
|
mask:
|
|
linear-gradient(#000 0 0) content-box exclude,
|
|
linear-gradient(#000 0 0);
|
|
animation: node-pulse var(--duration-pulse) linear forwards;
|
|
}
|
|
|
|
/*
|
|
* One lap at a constant rate, because the arc is travelling: an ease would
|
|
* read as braking into the finish. The element stays mounted until the next
|
|
* emit remounts it, so the lap has to put itself out on the way past.
|
|
*/
|
|
@keyframes node-pulse {
|
|
from {
|
|
--node-pulse-angle: 0turn;
|
|
}
|
|
75% {
|
|
opacity: 1;
|
|
}
|
|
to {
|
|
--node-pulse-angle: 1turn;
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* A node still working keeps the arc going instead: a run lasts as long as it
|
|
* lasts — a training is minutes — so the lap repeats and never fades out, and
|
|
* the ring is taken down when the node stops rather than by the animation.
|
|
* A loop rather than one-shot feedback, so none of the `--duration-*` tokens
|
|
* fit; the literal is one lap slow enough to read as travel, like the edge
|
|
* march above.
|
|
*/
|
|
.node-pulse.node-pulse-run {
|
|
animation: node-chase 1.2s linear infinite;
|
|
}
|
|
|
|
@keyframes node-chase {
|
|
to {
|
|
--node-pulse-angle: 1turn;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Handles are neutral: the one brand-secondary affordance here is Run. */
|
|
.react-flow__handle {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-width: 2px;
|
|
}
|
|
|
|
.react-flow__handle.unbound {
|
|
border-style: dashed;
|
|
border-color: color-mix(in srgb, var(--muted-foreground) 40%, transparent);
|
|
}
|
|
|
|
.react-flow__handle-connecting {
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.react-flow__node:focus-visible,
|
|
.react-flow__node:focus {
|
|
outline: none;
|
|
}
|
|
|
|
/*
|
|
* A keyboard has to see where it is. The outline is off above by design, so
|
|
* focus is answered the way a neuron answers it — by recolouring the node's own
|
|
* rim rather than by adding a ring around it. `--ring` equals `--primary`.
|
|
*/
|
|
.react-flow__node:focus-visible .flow-node-card {
|
|
border-color: var(--ring);
|
|
}
|
|
|
|
/*
|
|
* 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;
|
|
left: 50%;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
|
|
/*
|
|
* Embedded on Home rather than on a canvas of its own: the graph paints no
|
|
* surface, so the page's own background shows through behind the neurons.
|
|
*
|
|
* `--brain-decay` is how long a neuron or a connection takes to dim from its
|
|
* brightest back to the floor. Not a UI transition but the falloff of a signal,
|
|
* so it is measured in minutes rather than in the `--duration-*` tokens.
|
|
*/
|
|
.react-flow.brain-flat {
|
|
--xy-background-color: transparent;
|
|
--brain-decay: 150s;
|
|
/*
|
|
* Thicker than the editor's, to match the weight the mark draws them at — and
|
|
* the pulse widens with it, or a message would leave its edge looking thinner
|
|
* than it rests.
|
|
*/
|
|
--xy-edge-stroke-width: 3;
|
|
--edge-pulse-width: 4;
|
|
--xy-background-pattern-color: color-mix(
|
|
in srgb,
|
|
var(--muted-foreground) 22%,
|
|
transparent
|
|
);
|
|
}
|
|
|
|
/*
|
|
* 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 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(--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(--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(--brain-fill) 90%, transparent);
|
|
transition: none;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
.brain-cell {
|
|
transition:
|
|
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);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 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 {
|
|
--brain-gap: var(--ring);
|
|
}
|
|
|
|
/*
|
|
* A name under every circle is what makes the graph unreadable, so only the
|
|
* hovered or focused neuron carries one. Absolutely positioned: the layout's
|
|
* collide radius already holds this space clear, and revealing a label must not
|
|
* move anything.
|
|
*/
|
|
.brain-label {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
margin-top: 6px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
visibility: hidden;
|
|
}
|
|
|
|
.brain-cell:hover .brain-label,
|
|
.react-flow__node:focus-visible .brain-label,
|
|
.brain-label.brain-label-on {
|
|
visibility: visible;
|
|
}
|
|
|
|
/*
|
|
* 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(--foreground) 16%, transparent);
|
|
}
|
|
|
|
.brain-wire.brain-idle {
|
|
--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(--foreground) 80%, transparent);
|
|
}
|
|
|
|
.react-flow__edge-path.brain-edge {
|
|
stroke: var(--brain-ink);
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
|
|
/*
|
|
* 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: 2;
|
|
}
|
|
|
|
.brain-dot.brain-dot-out {
|
|
fill: var(--brain-ink);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
.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;
|
|
}
|
|
|
|
/*
|
|
* A value passing lights the line for the pulse and then decays; the dots sit
|
|
* on the same signal, so they flash with it instead of only following the
|
|
* falloff — same duration and easing as `edge-pulse`, and the same plain
|
|
* `--edge-rest` at the far end rather than the `color-mix()` the group rests
|
|
* in, which is what turned an interpolated pulse fluorescent.
|
|
*
|
|
* Two keyframe sets, because an animation beats a normal declaration whatever
|
|
* its specificity: animating `fill` for every dot would fill the hollow
|
|
* arriving end for the length of the pulse, and that hole is what tells the
|
|
* two ends apart.
|
|
*/
|
|
.brain-wire.brain-hot .brain-dot {
|
|
animation: brain-dot-pulse var(--duration-pulse) var(--ease-emphasized);
|
|
}
|
|
|
|
.brain-wire.brain-hot .brain-dot.brain-dot-out {
|
|
animation: brain-dot-out-pulse var(--duration-pulse) var(--ease-emphasized);
|
|
}
|
|
|
|
@keyframes brain-dot-pulse {
|
|
from {
|
|
stroke: var(--primary);
|
|
}
|
|
to {
|
|
stroke: var(--edge-rest);
|
|
}
|
|
}
|
|
|
|
@keyframes brain-dot-out-pulse {
|
|
from {
|
|
stroke: var(--primary);
|
|
fill: var(--primary);
|
|
}
|
|
to {
|
|
stroke: var(--edge-rest);
|
|
fill: var(--edge-rest);
|
|
}
|
|
}
|
|
}
|