feat(editor): wires carry their type — animated flow + canvas depth

A 2026-feel pass on the visual language. Three structural
moves that change how the editor reads at a glance.

1. Wires now carry the colour of the data they transport.

   Every edge leaving the inputs endpoint takes the
   declared input's type accent — blue for text, orange
   for bytes, purple for json, green for file, amber for
   number. The operator can trace "this is the document
   path, this is the prompt" by following colour, no
   labels needed. Edges leaving step outputs stay neutral
   (we don't have module manifests to look up the output
   type yet — once the hub exposes them, this lookup
   grows).

   Hover / selection still wins the colour treatment so
   the "I'm pointing at this one" signal isn't lost in
   the type palette.

2. Edges entering a currently-running step animate.

   New AnimationController on the canvas loops a 0..1
   phase at 1.5 s when ANY step is running, stopped
   otherwise (no idle cost). EdgePainter takes the phase
   and renders animated edges as marching dashes in the
   wire's accent colour over a 35%-alpha base — the
   operator literally sees data moving along the wire in
   the direction of flow while a step executes. When the
   step completes, the animation stops, the wire returns
   to a static stroke. Run a flow with a slow step and
   the canvas comes alive.

3. Canvas gets depth.

   - Background is now a subtle top-left → bottom-right
     gradient between surfaceContainer and surface,
     giving the working area a "lit centre, recessed
     corners" cue instead of one flat dark page.
   - Node cards swap their Material widget for an
     AnimatedContainer with two-layer shadows: a sharp
     short-blur shadow gives the card a real footprint,
     a soft long-blur shadow casts depth onto the canvas
     behind. Selected nodes get a third accent-coloured
     halo so selection state reads from across the canvas.
   - 180 ms ease-out-cubic transitions on the
     AnimatedContainer mean hover-into-select and
     run-status-changes morph smoothly instead of
     snapping.

Edge stroke widths bumped slightly (2.1 / 2.8 from 2.0 /
2.6) so the typed wires read at a confident weight against
the new gradient backdrop.

Version 0.6.0 -> 0.7.0 — visible visual language change.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 17:45:27 +02:00
parent 5ff7a1c118
commit 7aeeae717f
4 changed files with 208 additions and 43 deletions

View file

@ -63,8 +63,15 @@ class FlowCanvas extends StatefulWidget {
State<FlowCanvas> createState() => _FlowCanvasState();
}
class _FlowCanvasState extends State<FlowCanvas> {
class _FlowCanvasState extends State<FlowCanvas>
with SingleTickerProviderStateMixin {
final TransformationController _transform = TransformationController();
/// Continuous 0..1 loop that drives the marching-dash
/// animation on edges entering currently-running steps.
/// Stopped when no step is running so we don't burn frame
/// time on flows that are sitting idle.
late final AnimationController _flowAnim;
// Track which flow we last fitted-to-screen for so we
// don't override the operator's manual pan/zoom every
// build. Re-fit when the active flow changes.
@ -103,6 +110,10 @@ class _FlowCanvasState extends State<FlowCanvas> {
// indicator at the bottom-right of the canvas can update
// live as the operator pinches / scrolls.
_transform.addListener(_onTransformChanged);
_flowAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
}
@override
@ -110,6 +121,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
widget.controller.removeListener(_onControllerChanged);
_transform.removeListener(_onTransformChanged);
_transform.dispose();
_flowAnim.dispose();
super.dispose();
}
@ -121,7 +133,20 @@ class _FlowCanvasState extends State<FlowCanvas> {
}
void _onControllerChanged() {
if (mounted) setState(() {});
if (!mounted) return;
// Start the flow-animation controller only while a step
// is actually running; stop it otherwise so we don't
// tick every frame on idle flows.
final anyRunning = widget.controller.stepStatuses.values.any(
(s) => s == StepRunStatus.running,
);
if (anyRunning && !_flowAnim.isAnimating) {
_flowAnim.repeat();
} else if (!anyRunning && _flowAnim.isAnimating) {
_flowAnim.stop();
_flowAnim.value = 0;
}
setState(() {});
}
@override
@ -155,7 +180,21 @@ class _FlowCanvasState extends State<FlowCanvas> {
});
}
return Container(
color: theme.colorScheme.surface,
// Two-stop linear gradient gives the canvas a subtle
// depth cue corners feel slightly recessed, centre
// reads as the working area. Very low contrast so it
// doesn't compete with the nodes; just enough to make
// a flat dark page feel "lit" instead of "painted".
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.surfaceContainer,
theme.colorScheme.surface,
],
),
),
child: Stack(
children: [
InteractiveViewer(
@ -173,14 +212,18 @@ class _FlowCanvasState extends State<FlowCanvas> {
// Edges first so nodes paint on top of them.
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EdgePainter(
segments: _buildSegments(graph, layout),
baseColor: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.55),
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
portRadius: NodeGeometry.portDotSize / 2,
child: AnimatedBuilder(
animation: _flowAnim,
builder: (context, _) => CustomPaint(
painter: EdgePainter(
segments: _buildSegments(graph, layout),
baseColor: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.55),
highlightColor: theme.colorScheme.primary,
draftColor: theme.colorScheme.primary,
portRadius: NodeGeometry.portDotSize / 2,
phase: _flowAnim.value,
),
),
),
),
@ -616,6 +659,32 @@ class _FlowCanvasState extends State<FlowCanvas> {
_hoveredEdge == edgeKey ||
edge.fromId == widget.controller.selectedStepId ||
edge.toId == widget.controller.selectedStepId;
// Type-aware wire colour: edges leaving the inputs
// endpoint carry the declared type's accent so the
// operator can trace "this is the text input, this is
// the bytes input" by colour alone. Edges leaving step
// outputs use a softer "step result" colour because we
// don't have module manifests yet to know the actual
// output type. Once the hub exposes manifests, this
// lookup grows.
Color? wireColor;
if (edge.fromKind == EdgeEndpointKind.inputs) {
final input = graph.inputs[edge.fromField];
if (input != null) {
wireColor = _typeAccent(input.type, Theme.of(context));
}
}
// Edge is "live" when its target step is currently
// executing marching dashes show the operator data
// is moving on this wire RIGHT NOW.
final targetStatus = edge.toKind == EdgeEndpointKind.step
? widget.controller.stepStatuses[edge.toId]
: null;
final animated = targetStatus == StepRunStatus.running;
// Hover / selection always wins the colour treatment
// operators need a clear "I'm looking at this one"
// signal that overrides the type colour.
final color = highlight ? null : wireColor;
out.add(
EdgeSegment(
from: from,
@ -623,6 +692,8 @@ class _FlowCanvasState extends State<FlowCanvas> {
fromSide: fromSide,
toSide: toSide,
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
color: color,
animated: animated,
),
);
}