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
481 lines
15 KiB
CSS
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);
|
|
}
|
|
}
|
|
}
|