Files
app/frontend/src/components/Flow/flow.css
T
stroblmeandClaude Opus 5 0b2cc81d6a Correct the flow.css header: the canvas does pass colorMode
The comment said the canvas follows the theme without a `colorMode` prop. It
does not — React Flow stamps that prop on the wrapper as a class and defaults
it to `light`, which collided with the app's own `.light` token scope and
re-themed the whole canvas. The prop landed in 4e1898a; this is its reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZeGnqVsf5VHQqvz4HdUhN
2026-08-23 09:28:42 +02:00

481 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. The `colorMode` prop is passed all the same: React Flow
* stamps it on the wrapper as a class, and `.light` / `.dark` are the app's own
* token scopes — an unset one defaults to `light` and re-themes the whole canvas
* inside a dark shell. 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;
/*
* The line dragged between two ports is the edge it is about to become: the
* selected stroke at the same weight, so nothing shifts on release.
*/
--xy-connectionline-stroke: var(--primary);
--xy-connectionline-stroke-width: var(--xy-edge-stroke-width);
--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);
}
}
}