Brain: hover labels, activity falloff, rim-anchored directed edges
A name under every circle was most of what the graph drew, and none of it was what someone looks at the brain for. Only the hovered or keyboard-focused neuron names itself now — a failing one always does, since colour is never the only carrier of a status. The label is absolute and the collide radius already held that space, so revealing one moves nothing. Taking it out of flow also fixed the off-centre edges: the node box was as wide as its widest label, so a 32px circle inside a 140px box sat 54px left of where the layout put it and every edge aimed at the box rather than at the circle. Neurons are filled discs rather than `bg-card` outlines, which were a hairline against `--background` in light and close to nothing in dark. The fill is a neutral `--muted-foreground` alpha and stays neutral: status still speaks only through the border and the word beneath. Its lightness carries how recently the open page saw that neuron publish — brightest just after it fires, decaying over 90s to a floor it never drops below, so a graph left open sorts itself into what is busy and what is not. Edges do the same on their stroke. The signal is only what this session has seen on the socket, so a fresh page starts everything at a neutral middle rather than claiming a history it does not have. Both are CSS transitions gated on `prefers-reduced-motion`, decayed out of and snapped into, never the other way round. `BrainEdge` now carries both circle radii and trims its path back to the rims, with an arrowhead at the end it flows into. The barbs are two more segments of the same stroked path rather than an SVG marker, so they dim with the line they belong to. Neither trim reaches past the midpoint, so neurons closer together than their radii still get a line pointing the right way. `edge-pulse` reads its landing colour from `--edge-rest` instead of naming `--muted-foreground`, so a selected edge — which rests in blue — decays into its own colour instead of crossing to grey and snapping back at the end. Obsidian's graph view is the reference for the first three: filled nodes sized by degree, labels on a fade threshold with hover to recover them, and arrows as an explicit affordance. The falloff-by-opacity encoding is the standard one in the dynamic-graph literature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
+5
-9
@@ -25,8 +25,8 @@ should reopen it.
|
||||
- FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain)
|
||||
- FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms"
|
||||
- CHORE/UI `biome check ./src` reports an ineffective suppression at `FlowEditor.tsx:473` (`useExhaustiveDependencies` no longer fires there)
|
||||
- FEAT/UI text labels in the brain graph view should only show upon hovering. Edges/nodes which fire seldomly should dim out over time (up to a lower limit). Get inspiration on how "Obsidian" visualizes the graph view. Also make sure nodes shapes are visible on both light and dark themes (consider using fill color instead of shadows) -> web-search / research about visualization of large graphs and ensure high quality visual design
|
||||
- BUG/UI make sure the edges point towards the center of the nodes in the brain graph view (currently some of them seem a bit off-center)
|
||||
- FEAT/UI the brain's activity falloff is session-observed: a page just opened shows every neuron and connection at the same neutral base, and only sorts itself out as values arrive. A "last published" timestamp per node from the backend would let it open already sorted.
|
||||
- CHORE/UI the brain's hover labels have no touch equivalent — a tap navigates to the flow, and there is no hover to reveal a name first. The native `title` carries it on desktop only.
|
||||
|
||||
- FEAT/UI labels in flows (indicating dashboard widget connections) naturally can't pulse. Instead add an animation (enlightning fade) from either ltr or rtl depending if the label is in- or outbound
|
||||
- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s)
|
||||
@@ -42,8 +42,6 @@ is what M4 still waits on, together with porting the flows.
|
||||
|
||||
### Bugs found while building the screens
|
||||
|
||||
- BUG/API: `POST /alerts/test/{channel}` always answers 200. `AlertManager.send()` catches and logs every delivery failure, so the alerts screen's Test button cannot tell a working channel from a broken one — the one thing it exists for. Let `send()` raise or return a result on the test path.
|
||||
- BUG/FLOW: deleting a flow leaves its `pipeline:{flow}.*` Redis keys behind, and renaming one does not migrate them — the live instance carries `pipeline:__history__:dashboar.test` beside the correct `dashboard.test`. One cleanup on the delete/rename path covers both.
|
||||
- CHORE/API: revoking an OAuth client does not invalidate access tokens already issued; they are stateless JWTs valid up to `MCP_TOKEN_EXPIRE_MINUTES`. Immediate revocation means `app/mcp/http.py` checking the client row still exists.
|
||||
- CHORE/FLOW: `Pipeline.trigger`'s docstring says a paused flow still publishes so the value shows on the canvas. True only without a queue; with one the item parks before `apply_outputs` and nothing shows. Docstring and behaviour disagree.
|
||||
- CHORE/FLOW: `_to_messages` keeps its `if not retval: return None` guard ahead of the new type check, so a falsy non-dict return (`0`, `""`, `[]`) is still silently "no output" rather than the named error. Deliberate for now; worth a decision.
|
||||
@@ -52,7 +50,6 @@ is what M4 still waits on, together with porting the flows.
|
||||
|
||||
### Out-of-process nodes and modules
|
||||
|
||||
- BUG/API: `POST /flows/{name}/nodes/{node_id}/trigger` answers 500 when the node's code raises, because the inline trigger path runs `Node.__call__` rather than `Pipeline._execute_node` and nothing catches it. Predates the worker pool, which only made it easier to hit; the person waiting on the response should get the node's error, not a stack trace in the server log.
|
||||
- CHORE/FLOW: `PythonWorkerPool._running` is keyed by node id and last-wins, so two concurrent runs of one node mean cancel kills the newest. Key by run id once M5's run records exist.
|
||||
- CHORE/FLOW: `compile_check` sends the *draft* source under the running node's cache key, so the worker recompiles the published source on its next call. Correct, but one wasted compile per save on a busy node.
|
||||
- FEAT/API: `POST /modules/apply` rebuilds the whole pipeline so a node that could not import its package stops being red. That resubscribes every MQTT node in the deployment; a targeted rebuild of the flows that actually failed to load would be gentler.
|
||||
@@ -69,9 +66,9 @@ is what M4 still waits on, together with porting the flows.
|
||||
- FEAT/UI: the health screen's window is fixed at 24 hours and the charts fold minute buckets in Python. A range picker (and `date_bin()` behind it) is the next step if anyone wants a week.
|
||||
- CHORE/FLOW: run records for a deleted flow stay until the retention window passes, so a flow that no longer exists keeps appearing in the history. Deliberate — it is a record of what ran — but `forget_flow` could offer to clear it.
|
||||
- CHORE/API: nothing can ask the collector to flush now, so anything needing the tables to be current has to wait out `FLUSH_INTERVAL_S` — which is what the soak harness does before clearing its own rows.
|
||||
- BUG/UI: `MemoryWorkQueue.stats()` hard-codes `pending: 0`, so the health tile always reads zero on a stack without Redis.
|
||||
- CHORE/API: `MetricsCollector._start_run`'s `existing is not None` branch is unreachable: a redelivery only arrives after the record it would update has been dropped.
|
||||
- CHORE/API: the collector's `_tracebacks` stash is never pruned for nodes that stop failing — one entry per node that ever errored, kept for the life of the process.
|
||||
- CHORE/FLOW: `RedisWorkQueue.clear_flow` deletes only `pipeline:__parked__:{flow}`, so a deleted or renamed flow's `__queue__` stream entries, `__delayed__` zset members and `__done__:*` markers stay behind. The stream is capped and the entries are dropped when they reach a node that no longer exists, so it costs work rather than correctness.
|
||||
- CHORE/FLOW: `MemoryWorkQueue`'s in-flight count is a counter around claim/ack, and claiming already removed the item — so an item a handler leaves unacknowledged (no pipeline bound) counts as in flight until the process ends. Nothing can hand it back either way, which is what the memory queue is.
|
||||
- CHORE/API: audit rows ride the same drop-oldest bus as telemetry, so a storm can lose one. Writing a node's source is not audited either; publishing is.
|
||||
- PERF/API: two unmarked ceilings — `/observability/flows` scans every `metric_minute` row in the window on each 30 s poll, and `queue.stats()` does a keyspace `scan_iter` on every call while two endpoints poll it.
|
||||
- CHORE/INFRA: dev only — memory-queue ids (`mem-{seq}`) restart at 0 each boot and `FlowRun.id` is the primary key, so a restart without Redis upserts over the previous boot's run rows.
|
||||
@@ -182,8 +179,7 @@ as an em dash.
|
||||
- CHORE/UX: free-form params (python nodes) get no suggestions, since there is no schema to key them off.
|
||||
- PERF/UI: `BrainView` runs 300 force-layout ticks synchronously inside a `useMemo`, so the graph is laid out on the render thread.
|
||||
- FEAT/UI: the brain is a band on a scrolling page now, so it neither pans nor zooms — the fit keeps the whole graph in view instead. An installation with enough flows to make the labels unreadable at that fit needs a way to open the graph larger.
|
||||
- FEAT/UI: the brain graph draws connections as plain lines, so which way a value travels is not shown. An arrowhead needs the path shortened to the target circle's rim, which needs the node radius on the edge.
|
||||
- CHORE/UI: fixing the yellow message pulse gave `edge-pulse` an explicit `to`, so a *selected* edge now decays to `--muted-foreground` and snaps back to its blue at the end instead of decaying into it. Barely visible, and only while an edge is selected.
|
||||
- CHORE/UI: `flow.css` drops the focus outline on every `.react-flow__node`. Brain neurons now answer `:focus-visible` with the same border colour as hover, but the flow editor's own nodes still have no visible keyboard focus.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEdge, type EdgeProps, getStraightPath } from "@xyflow/react"
|
||||
import { BaseEdge, type EdgeProps } from "@xyflow/react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { duration } from "@/lib/motion"
|
||||
@@ -8,9 +8,55 @@ import { useLatestTs } from "./liveStore"
|
||||
export type BrainEdgeData = {
|
||||
/** Every message this connection carries, qualified. */
|
||||
messages: string[]
|
||||
/** Circle radii of the two neurons, so the line can stop at their rims. */
|
||||
sourceRadius: number
|
||||
targetRadius: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Arrowhead barb length, and how far its tip stops short of the target rim. */
|
||||
const HEAD = 7
|
||||
const RIM_GAP = 3
|
||||
|
||||
/**
|
||||
* A line from rim to rim, with an arrowhead at the end it flows into.
|
||||
*
|
||||
* Both handles sit at a neuron's centre, so the raw path runs under both
|
||||
* circles: it looks off-centre wherever the circles differ in size, and it says
|
||||
* nothing about which way the value travels. The barbs are two more segments of
|
||||
* the same stroked path rather than an SVG marker, so they dim with the line
|
||||
* they belong to instead of needing a fill of their own.
|
||||
*/
|
||||
function rimPath(
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
sourceRadius: number,
|
||||
targetRadius: number,
|
||||
): string {
|
||||
const length = Math.hypot(targetX - sourceX, targetY - sourceY) || 1
|
||||
const unitX = (targetX - sourceX) / length
|
||||
const unitY = (targetY - sourceY) / length
|
||||
// Neither trim reaches past the midpoint, so two neurons closer together than
|
||||
// their radii still get a line pointing the right way rather than a backwards
|
||||
// one. The force layout keeps them apart; this only stops it looking broken.
|
||||
const from = Math.min(sourceRadius, length / 2)
|
||||
const to = Math.min(targetRadius + RIM_GAP, length - from)
|
||||
const startX = sourceX + unitX * from
|
||||
const startY = sourceY + unitY * from
|
||||
const tipX = targetX - unitX * to
|
||||
const tipY = targetY - unitY * to
|
||||
const line = `M${startX},${startY}L${tipX},${tipY}`
|
||||
if (length - from - to < HEAD) return line
|
||||
|
||||
const backX = tipX - unitX * HEAD
|
||||
const backY = tipY - unitY * HEAD
|
||||
const spreadX = (-unitY * HEAD) / 2
|
||||
const spreadY = (unitX * HEAD) / 2
|
||||
return `${line}M${backX + spreadX},${backY + spreadY}L${tipX},${tipY}L${backX - spreadX},${backY - spreadY}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection between neurons, lit by whatever passes along it.
|
||||
*
|
||||
@@ -26,12 +72,23 @@ function BrainEdgeComponent({
|
||||
targetY,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const { messages } = (data ?? { messages: [] }) as BrainEdgeData
|
||||
const { messages, sourceRadius, targetRadius } = (data ?? {
|
||||
messages: [],
|
||||
sourceRadius: 0,
|
||||
targetRadius: 0,
|
||||
}) as BrainEdgeData
|
||||
const ts = useLatestTs(messages)
|
||||
const [pulsing, setPulsing] = useState(false)
|
||||
const lastTs = useRef(0)
|
||||
|
||||
const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY })
|
||||
const path = rimPath(
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourceRadius,
|
||||
targetRadius,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ts || ts === lastTs.current) return
|
||||
@@ -41,7 +98,18 @@ function BrainEdgeComponent({
|
||||
return () => clearTimeout(timer)
|
||||
}, [ts])
|
||||
|
||||
return <BaseEdge id={id} path={path} className={cn(pulsing && "edge-live")} />
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
className={cn(
|
||||
"brain-edge",
|
||||
// Nothing seen yet this session is not the same as seen and gone quiet.
|
||||
ts === 0 && "brain-idle",
|
||||
pulsing && "brain-hot edge-live",
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrainEdge = memo(BrainEdgeComponent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||
import { memo } from "react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
|
||||
import { duration } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useGroupEmits, useGroupError } from "./liveStore"
|
||||
|
||||
@@ -19,22 +20,32 @@ function BrainNodeComponent({ data }: NodeProps) {
|
||||
const { label, kind, members, flows, size } = data as BrainNodeData
|
||||
const emits = useGroupEmits(members)
|
||||
const failed = useGroupError(members)
|
||||
// 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.
|
||||
const [firing, setFiring] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!emits) return
|
||||
setFiring(true)
|
||||
const timer = setTimeout(() => setFiring(false), duration.pulse * 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [emits])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center"
|
||||
title={`${kind} · ${members.join(", ")}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"brain-cell relative flex items-center justify-center rounded-full border bg-card shadow-e1",
|
||||
"brain-cell relative flex items-center justify-center rounded-full border",
|
||||
// Nothing seen yet this session is not the same as seen and gone quiet.
|
||||
emits === 0 && "brain-idle",
|
||||
firing && "brain-hot",
|
||||
failed ? "border-destructive" : "border-border",
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
title={`${label} · ${kind} · ${members.join(", ")}`}
|
||||
>
|
||||
{/* Remounting on each emit is what restarts the animation. */}
|
||||
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
|
||||
{/* Both ends sit at the centre, so an edge runs neuron to neuron. */}
|
||||
{/* Both ends sit at the centre; the edge trims itself back to the rim. */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
@@ -47,18 +58,23 @@ function BrainNodeComponent({ data }: NodeProps) {
|
||||
className="brain-handle"
|
||||
isConnectable={false}
|
||||
/>
|
||||
<span className="px-1 text-center text-xs font-medium text-muted-foreground">
|
||||
<span className="px-1 text-center text-xs font-medium text-foreground">
|
||||
{flows.length > 1 ? flows.length : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1.5 max-w-[140px] truncate text-center text-xs font-medium">
|
||||
{/*
|
||||
* 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 a
|
||||
* failing one, since colour is never the only carrier of a status.
|
||||
*/}
|
||||
<span className={cn("brain-label", failed && "brain-label-on")}>
|
||||
<span className="max-w-[140px] truncate text-xs font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{/* Colour is never the only carrier of a status. */}
|
||||
{failed ? (
|
||||
<span className="text-xs font-medium text-destructive">failed</span>
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useEffect, useMemo } from "react"
|
||||
|
||||
import type { BrainGraph } from "@/client"
|
||||
import { scaleIn } from "@/lib/motion"
|
||||
import { BrainEdge } from "./BrainEdge"
|
||||
import { BrainEdge, type BrainEdgeData } from "./BrainEdge"
|
||||
import { BrainNode, type BrainNodeData } from "./BrainNode"
|
||||
import "./flow.css"
|
||||
import { graphQueryOptions } from "./queries"
|
||||
@@ -38,7 +38,10 @@ const edgeTypes = { brain: BrainEdge }
|
||||
/** Circle diameter, from how many neurons this one is wired to. */
|
||||
const SIZE_MIN = 32
|
||||
const SIZE_MAX = 96
|
||||
/** Room around a circle for its label, so the layout does not overlap them. */
|
||||
/**
|
||||
* Room around a circle for the label it shows on hover. Reserved in the layout
|
||||
* rather than in the node box, so revealing a label moves nothing.
|
||||
*/
|
||||
const LABEL_ROOM = 26
|
||||
|
||||
type Placed = SimulationNodeDatum & { id: string; size: number; room: number }
|
||||
@@ -105,6 +108,10 @@ function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
|
||||
.stop()
|
||||
.tick(300)
|
||||
|
||||
// Both of an edge's handles sit at a neuron's centre, so the edge is what has
|
||||
// to trim itself back to the rims; the radius is only known here.
|
||||
const radius = new Map(placed.map((node) => [node.id, node.size / 2]))
|
||||
|
||||
return {
|
||||
nodes: placed.map((node, index) => {
|
||||
const source = (graph.nodes ?? [])[index]
|
||||
@@ -130,7 +137,11 @@ function build(graph: BrainGraph): { nodes: Node[]; edges: Edge[] } {
|
||||
type: "brain",
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
data: { messages: edge.messages ?? [] },
|
||||
data: {
|
||||
messages: edge.messages ?? [],
|
||||
sourceRadius: radius.get(edge.source) ?? 0,
|
||||
targetRadius: radius.get(edge.target) ?? 0,
|
||||
} satisfies BrainEdgeData,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,19 @@
|
||||
--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);
|
||||
}
|
||||
|
||||
/* A message arriving lights its edge, then decays back to rest. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.react-flow__edge-path.edge-live {
|
||||
@@ -41,8 +54,7 @@
|
||||
* 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. Landing on `--muted-foreground` instead is
|
||||
* the same hue the edge rests in, so the last step back is invisible.
|
||||
* every pulse fluorescent yellow.
|
||||
*/
|
||||
@keyframes edge-pulse {
|
||||
from {
|
||||
@@ -50,7 +62,7 @@
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
to {
|
||||
stroke: var(--muted-foreground);
|
||||
stroke: var(--edge-rest);
|
||||
stroke-width: var(--xy-edge-stroke-width);
|
||||
}
|
||||
}
|
||||
@@ -102,8 +114,8 @@
|
||||
}
|
||||
|
||||
/*
|
||||
* Brain graph. Both ends of a connection sit at the neuron's centre, so an
|
||||
* edge runs straight from circle to circle and disappears under them.
|
||||
* Brain graph. Both ends of a connection sit at the neuron's centre, which is
|
||||
* what `BrainEdge` measures its rim trim and its arrowhead from.
|
||||
*/
|
||||
.brain-handle {
|
||||
opacity: 0;
|
||||
@@ -115,16 +127,110 @@
|
||||
/*
|
||||
* 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: 90s;
|
||||
}
|
||||
|
||||
/*
|
||||
* A neuron is a filled disc rather than an outline: a `--card` circle on
|
||||
* `--background` is a hairline in light and all but nothing in dark.
|
||||
*
|
||||
* The fill is neutral, and stays neutral — status still speaks only through the
|
||||
* border and the word under it. What its 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. A fresh page has seen nothing either way, so everything
|
||||
* starts at `.brain-idle`, halfway, claiming no history it does not have.
|
||||
*/
|
||||
.brain-cell {
|
||||
cursor: pointer;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground) 28%,
|
||||
transparent
|
||||
);
|
||||
transition: border-color var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.brain-cell:hover {
|
||||
.brain-cell.brain-idle {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground) 42%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Snapped into, decayed out of — never the other way round. */
|
||||
.brain-cell.brain-hot {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-foreground) 65%,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.brain-cell:hover,
|
||||
.react-flow__node:focus-visible .brain-cell {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 line instead of the disc. */
|
||||
.react-flow__edge-path.brain-edge {
|
||||
stroke: color-mix(in srgb, var(--muted-foreground) 38%, transparent);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.react-flow__edge-path.brain-edge.brain-idle {
|
||||
stroke: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
|
||||
}
|
||||
|
||||
.react-flow__edge-path.brain-edge.brain-hot {
|
||||
stroke: color-mix(in srgb, var(--muted-foreground) 85%, transparent);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.react-flow__edge-path.brain-edge {
|
||||
transition: stroke var(--brain-decay) linear;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user