Send a node's emit pulse round the rim instead of scaling it out
The ring hung off the node itself, and `inset` on an absolute child resolves against the padding box — so on the brain's thick status ring it landed inside the outer edge and painted over the status the ring carries. Both node shapes now hang it off a border-less wrapper, where the same offsets clear the rim whatever border the node draws (measured: 4px on all four sides, on a 1px card border and a 7px neuron ring alike). The shape is a conic-gradient arc masked to the padding band, running one lap in `--duration-pulse` and going out on the way past. Note: BrainNode.tsx also carries an unrelated in-flight change from a concurrent session (the `seen` ref gating the pulse on a real emit); the two could not be separated in one file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,6 @@ should reopen it.
|
|||||||
- CHORE/UI: a streaming edge's dashes sit inside the `prefers-reduced-motion: no-preference` guard, so a reduced-motion user gets no streaming indicator at all. Lifting `stroke-dasharray` alone out of the guard keeps the static signal without the motion.
|
- CHORE/UI: a streaming edge's dashes sit inside the `prefers-reduced-motion: no-preference` guard, so a reduced-motion user gets no streaming indicator at all. Lifting `stroke-dasharray` alone out of the guard keeps the static signal without the motion.
|
||||||
- CHORE/UI: only node-to-node edges carry the streaming dash. `endpoints.ts` builds a dashboard endpoint's edges without the producing port's `stream` flag, so a training loss feeding a chart — the case the march was drawn for — is still a plain line.
|
- CHORE/UI: only node-to-node edges carry the streaming dash. `endpoints.ts` builds a dashboard endpoint's edges without the producing port's `stream` flag, so a training loss feeding a chart — the case the march was drawn for — is still a plain line.
|
||||||
- CHORE/UI: the Home flow-activity table's name cell is `max-w-0` with nothing setting a floor, so `demo_training` reads as "demo⋯" at 1440px while the row has slack to spare. The cap is what keeps the table from widening the page; it wants a minimum beside it.
|
- CHORE/UI: the Home flow-activity table's name cell is `max-w-0` with nothing setting a floor, so `demo_training` reads as "demo⋯" at 1440px while the row has slack to spare. The cap is what keeps the table from widening the page; it wants a minimum beside it.
|
||||||
- CHORE/UI: `.node-pulse`'s `inset: -3px` resolves against the padding box, so on a thick border it lands inside the outer edge and paints over the status ring rather than sitting one ring outward as its comment claims. Measured on a 9px border — the comment and the geometry disagree.
|
|
||||||
- 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)
|
- 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)
|
||||||
- FEAT/UI make the header (Fluksio - YEAR) and the logo in the sidebar link to the main page (fluksio.com)
|
- FEAT/UI make the header (Fluksio - YEAR) and the logo in the sidebar link to the main page (fluksio.com)
|
||||||
- FEAT/UI consider adding a diagram to the Home view which shows a histogram of the different classes of nodes and which time it takes to execute (logarithmic scale); this should give a hint on the load and help to detect bottle necks/hotspots
|
- FEAT/UI consider adding a diagram to the Home view which shows a histogram of the different classes of nodes and which time it takes to execute (logarithmic scale); this should give a hint on the load and help to detect bottle necks/hotspots
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||||
import { memo, useEffect, useState } from "react"
|
import { memo, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
import { duration } from "@/lib/motion"
|
import { duration } from "@/lib/motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useGroupEmits, useGroupError } from "./liveStore"
|
import { useGroupActive, useGroupEmits, useGroupError } from "./liveStore"
|
||||||
|
|
||||||
export type BrainNodeData = {
|
export type BrainNodeData = {
|
||||||
label: string
|
label: string
|
||||||
@@ -44,6 +44,9 @@ function BrainNodeComponent({ data }: NodeProps) {
|
|||||||
const { label, kind, members, flows, size, issue, revealed } =
|
const { label, kind, members, flows, size, issue, revealed } =
|
||||||
data as BrainNodeData
|
data as BrainNodeData
|
||||||
const emits = useGroupEmits(members)
|
const emits = useGroupEmits(members)
|
||||||
|
// Whether it has published at all, which the snapshot answers for what
|
||||||
|
// happened before this page connected; `emits` only counts what we saw.
|
||||||
|
const active = useGroupActive(members)
|
||||||
const failed = useGroupError(members)
|
const failed = useGroupError(members)
|
||||||
// Two faults, told apart the way the mark's two parts are: the ring is the
|
// Two faults, told apart the way the mark's two parts are: the ring is the
|
||||||
// wiring around the node, so a flow that cannot run as written colours the
|
// wiring around the node, so a flow that cannot run as written colours the
|
||||||
@@ -52,82 +55,97 @@ function BrainNodeComponent({ data }: NodeProps) {
|
|||||||
// 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.
|
||||||
const [firing, setFiring] = useState(false)
|
const [firing, setFiring] = useState(false)
|
||||||
|
// The count outlives this component: the store is module-level, and a
|
||||||
|
// snapshot restores what the engine counted before the page even loaded. So
|
||||||
|
// the number we mount with is history, and only a change on top of it is
|
||||||
|
// something that just happened.
|
||||||
|
const seen = useRef(emits)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!emits) return
|
if (emits === seen.current) return
|
||||||
|
seen.current = emits
|
||||||
setFiring(true)
|
setFiring(true)
|
||||||
const timer = setTimeout(() => setFiring(false), duration.pulse * 1000)
|
const timer = setTimeout(() => setFiring(false), duration.pulse * 1000)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [emits])
|
}, [emits])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
// The pulse ring measures itself from here rather than from the neuron, so
|
||||||
className={cn(
|
// the thick status ring stays clear of it — see `.node-pulse` in flow.css.
|
||||||
"brain-cell relative flex items-center justify-center rounded-full",
|
<span className="relative flex rounded-full">
|
||||||
// Nothing seen yet this session is not the same as seen and gone quiet.
|
{/* Remounting on each emit is what restarts the animation, so this is
|
||||||
emits === 0 && "brain-idle",
|
gated on the pulse rather than on the count: a neuron mounting with a
|
||||||
firing && "brain-hot",
|
count already in the store would otherwise play it once for free. */}
|
||||||
failed && "brain-fault",
|
{firing ? <span key={emits} className="node-pulse" /> : null}
|
||||||
issue ? "border-brand-secondary" : "border-foreground",
|
|
||||||
)}
|
|
||||||
// Border-box, so the outer rim stays at `size` and the edges keep landing
|
|
||||||
// on it however thick the ring gets. The gap between ring and disc is an
|
|
||||||
// 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: Math.round(size * RING),
|
|
||||||
boxShadow: `inset 0 0 0 ${Math.round(size * GAP)}px var(--brain-gap)`,
|
|
||||||
}}
|
|
||||||
title={`${label} · ${kind} · ${members.join(", ")}${issue ? ` · ${issue}` : ""}`}
|
|
||||||
>
|
|
||||||
{/* Remounting on each emit is what restarts the animation. */}
|
|
||||||
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
|
|
||||||
{/* Both ends sit at the centre; the edge trims itself back to the rim. */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="brain-handle"
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="brain-handle"
|
|
||||||
isConnectable={false}
|
|
||||||
/>
|
|
||||||
<span className="px-1 text-center text-xs font-medium text-foreground">
|
|
||||||
{flows.length > 1 ? flows.length : null}
|
|
||||||
</span>
|
|
||||||
{/*
|
|
||||||
* 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
|
|
||||||
* one with something wrong, since colour is never the only carrier of a
|
|
||||||
* status, and here it carries two of them. Two words rather than the
|
|
||||||
* reason itself: a cycle names every node in it, which is a sentence no
|
|
||||||
* label under a 32px circle can hold, so the phrase matches what Home
|
|
||||||
* 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
|
<span
|
||||||
className={cn("brain-label", (problem || revealed) && "brain-label-on")}
|
className={cn(
|
||||||
|
"brain-cell relative flex items-center justify-center rounded-full",
|
||||||
|
// Nothing seen yet this session is not the same as seen and gone quiet.
|
||||||
|
!active && "brain-idle",
|
||||||
|
firing && "brain-hot",
|
||||||
|
failed && "brain-fault",
|
||||||
|
issue ? "border-brand-secondary" : "border-foreground",
|
||||||
|
)}
|
||||||
|
// Border-box, so the outer rim stays at `size` and the edges keep landing
|
||||||
|
// on it however thick the ring gets. The gap between ring and disc is an
|
||||||
|
// 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: Math.round(size * RING),
|
||||||
|
boxShadow: `inset 0 0 0 ${Math.round(size * GAP)}px var(--brain-gap)`,
|
||||||
|
}}
|
||||||
|
title={`${label} · ${kind} · ${members.join(", ")}${issue ? ` · ${issue}` : ""}`}
|
||||||
>
|
>
|
||||||
<span className="max-w-[140px] truncate text-xs font-medium">
|
{/* Both ends sit at the centre; the edge trims itself back to the rim. */}
|
||||||
{label}
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className="brain-handle"
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className="brain-handle"
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
<span className="px-1 text-center text-xs font-medium text-foreground">
|
||||||
|
{flows.length > 1 ? flows.length : null}
|
||||||
</span>
|
</span>
|
||||||
{/* The label box is only as wide as the circle it hangs under, so two
|
{/*
|
||||||
words break onto two lines unless told not to. */}
|
* A name under every circle is what makes the graph unreadable, so only
|
||||||
{problem ? (
|
* the neuron under the pointer or the keyboard says what it is — except
|
||||||
<span className="whitespace-nowrap text-xs font-medium text-foreground">
|
* one with something wrong, since colour is never the only carrier of a
|
||||||
{[failed && "failed", issue && "cannot run"]
|
* status, and here it carries two of them. Two words rather than the
|
||||||
.filter(Boolean)
|
* reason itself: a cycle names every node in it, which is a sentence no
|
||||||
.join(" · ")}
|
* label under a 32px circle can hold, so the phrase matches what Home
|
||||||
|
* 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 || revealed) && "brain-label-on",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="max-w-[140px] truncate text-xs font-medium">
|
||||||
|
{label}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
{/* The label box is only as wide as the circle it hangs under, so two
|
||||||
|
words break onto two lines unless told not to. */}
|
||||||
|
{problem ? (
|
||||||
|
<span className="whitespace-nowrap text-xs font-medium text-foreground">
|
||||||
|
{[failed && "failed", issue && "cannot run"]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -138,103 +138,106 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
|
|||||||
const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
|
const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
// The pulse ring measures itself from here rather than from the card, so
|
||||||
className={cn(
|
// a border can never land on top of it — see `.node-pulse` in flow.css.
|
||||||
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
|
<div className="relative rounded-lg">
|
||||||
selected && "border-primary shadow-e2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Remounting on each emit is what restarts the animation. */}
|
{/* Remounting on each emit is what restarts the animation. */}
|
||||||
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
|
{emits > 0 ? <span key={emits} className="node-pulse" /> : null}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative min-w-[168px] max-w-[220px] rounded-lg border border-border bg-card px-3 py-2.5 shadow-e1 transition-shadow",
|
||||||
|
selected && "border-primary shadow-e2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PortHandles
|
||||||
|
specs={definition.requires ?? []}
|
||||||
|
type="target"
|
||||||
|
position={vertical ? Position.Top : Position.Left}
|
||||||
|
/>
|
||||||
|
|
||||||
<PortHandles
|
<div className="flex items-center gap-2.5">
|
||||||
specs={definition.requires ?? []}
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-primary">
|
||||||
type="target"
|
<Icon className="size-4" />
|
||||||
position={vertical ? Position.Top : Position.Left}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-primary/10 text-primary">
|
|
||||||
<Icon className="size-4" />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block truncate text-sm font-medium">
|
|
||||||
{definition.title || definition.id}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
<span className="min-w-0 flex-1">
|
||||||
{typeLabel}
|
<span className="block truncate text-sm font-medium">
|
||||||
|
{definition.title || definition.id}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
{status === "running" ? (
|
||||||
{status === "running" ? (
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon-sm"
|
||||||
size="icon-sm"
|
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
aria-label="Stop this node"
|
||||||
aria-label="Stop this node"
|
data-testid="node-cancel"
|
||||||
data-testid="node-cancel"
|
onClick={(event) => {
|
||||||
onClick={(event) => {
|
event.stopPropagation()
|
||||||
event.stopPropagation()
|
// Best effort by nature: it may well have finished between
|
||||||
// Best effort by nature: it may well have finished between
|
// the render and the click, which is the outcome asked for.
|
||||||
// the render and the click, which is the outcome asked for.
|
FlowsService.cancelNode({
|
||||||
FlowsService.cancelNode({
|
name: flow,
|
||||||
name: flow,
|
nodeId: definition.id,
|
||||||
nodeId: definition.id,
|
}).catch(() => {})
|
||||||
}).catch(() => {})
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Square />
|
||||||
<Square />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>Stop this node</TooltipContent>
|
||||||
<TooltipContent>Stop this node</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
) : null}
|
||||||
) : null}
|
|
||||||
|
|
||||||
{status === "error" && onShowLogs ? (
|
{status === "error" && onShowLogs ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
// `nodrag` keeps xyflow from reading the press as a drag; the
|
// `nodrag` keeps xyflow from reading the press as a drag; the
|
||||||
// click itself is stopped so the node panel stays closed.
|
// click itself is stopped so the node panel stays closed.
|
||||||
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
aria-label="Show what this node printed"
|
aria-label="Show what this node printed"
|
||||||
data-testid="node-traceback"
|
data-testid="node-traceback"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onShowLogs(definition.id)
|
onShowLogs(definition.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Bug />
|
<Bug />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Show the traceback</TooltipContent>
|
<TooltipContent>Show the traceback</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{style ? (
|
{style ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
role="img"
|
role="img"
|
||||||
className={cn("size-2 shrink-0 rounded-full", style.dot)}
|
className={cn("size-2 shrink-0 rounded-full", style.dot)}
|
||||||
aria-label={style.label}
|
aria-label={style.label}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-h-60 max-w-xs overflow-y-auto whitespace-pre-line break-words">
|
<TooltipContent className="max-h-60 max-w-xs overflow-y-auto whitespace-pre-line break-words">
|
||||||
{problem || style.label}
|
{problem || style.label}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PortHandles
|
||||||
|
specs={definition.provides ?? []}
|
||||||
|
type="source"
|
||||||
|
position={vertical ? Position.Bottom : Position.Right}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PortHandles
|
|
||||||
specs={definition.provides ?? []}
|
|
||||||
type="source"
|
|
||||||
position={vertical ? Position.Bottom : Position.Right}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,26 +99,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A node that just published something says so, once, and settles. */
|
/*
|
||||||
|
* A node that just published something says so, once, and settles: an arc runs
|
||||||
|
* one lap of the rim and goes out.
|
||||||
|
*
|
||||||
|
* 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) {
|
@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.
|
||||||
|
*/
|
||||||
.node-pulse {
|
.node-pulse {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -3px;
|
inset: -4px;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
border: 2px solid var(--primary);
|
padding: 2px;
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: node-pulse var(--duration-pulse) var(--ease-emphasized) forwards;
|
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 {
|
@keyframes node-pulse {
|
||||||
from {
|
from {
|
||||||
|
--node-pulse-angle: 0turn;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
|
--node-pulse-angle: 1turn;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(1.09);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user