feat(editor): reduce-motion clamp + edge gradient + breathing pulse + style sheet

Four polish features in one push.

1. Reduce-motion clamp. FaiEditorStyle.clampedForA11y(
   disableAnimations: ...) returns a style with glass, gradient,
   flow animation, and node shadows forced off when the OS
   reduce-motion preference is set. FlowCanvas + FlowEditorPage
   call it on every build with
   MediaQuery.disableAnimationsOf(context) so operators on
   accessibility settings get a flat, static editor without an
   explicit Studio setting.

2. Edge gradient source→target. EdgePainter now accepts an
   optional colorEnd per segment and paints a linear shader
   along the bezier when both ends carry distinct type accents.
   In practice this draws attention to type mismatches: a
   text→json wire reads as a colour transition; a same-type
   wire stays a solid colour. ModuleSpec lookup at both edge
   endpoints feeds the colour pair.

3. Breathing pulse on running steps. FlowNode accepts a
   Listenable pulse; when status=running AND the canvas's
   _flowAnim is ticking, the node wraps in an AnimatedBuilder
   that drives a sine-wave shadow halo (alpha + blur + spread
   oscillating). Stops when the run completes. Gated by
   _style.flowAnimation so reduce-motion / minimal-style modes
   stay still.

4. In-editor style sheet. Bottom-right canvas chrome gains a
   ⚙ Style button that opens a modal bottom sheet with four
   SwitchListTiles (glass, gradient backdrop, flow animation,
   node shadows) plus 'Reset to default'. Edits live in
   _styleOverride on the canvas state — operator can tweak the
   look for the session without restarting or editing code.
   No persistence in this version; follow-up could pipe the
   override through SharedPreferences via the host.

Bumps fai_studio_flow_editor to 0.10.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 22:55:18 +02:00
parent b1fe765468
commit a71f654162
6 changed files with 377 additions and 73 deletions

View file

@ -104,4 +104,27 @@ class FaiEditorStyle {
panelBackgroundAlpha: panelBackgroundAlpha ?? this.panelBackgroundAlpha,
);
}
/// Returns a style clamped to the operator's accessibility
/// preferences. When `disableAnimations` (the OS-level
/// "Reduce Motion" toggle, surfaced through Flutter's
/// MediaQuery) is set, motion-dependent features collapse
/// to their off variants regardless of what the caller
/// declared:
/// - canvasBackdrop flat
/// - panelStyle solid
/// - flowAnimation false
/// - nodeShadows false
/// Tints + colours are unaffected.
FaiEditorStyle clampedForA11y({required bool disableAnimations}) {
if (!disableAnimations) return this;
return FaiEditorStyle(
panelStyle: EditorPanelStyle.solid,
canvasBackdrop: EditorCanvasBackdrop.flat,
flowAnimation: false,
nodeShadows: false,
panelBlurSigma: panelBlurSigma,
panelBackgroundAlpha: panelBackgroundAlpha,
);
}
}

View file

@ -454,7 +454,12 @@ outputs:
// operator's first instinct is the right action rather
// than staring at an empty grid.
final hasSteps = _controller.graph.steps.isNotEmpty;
final style = widget.style ?? FaiEditorStyle.modern;
// Honor the OS reduce-motion preference for the
// properties-panel glass effect same clamp the canvas
// applies to its own surfaces.
final style = (widget.style ?? FaiEditorStyle.modern).clampedForA11y(
disableAnimations: MediaQuery.disableAnimationsOf(context),
);
final glass = style.panelStyle == EditorPanelStyle.glass;
final hasSelection = _controller.selectedStepId != null;

View file

@ -9,6 +9,8 @@
// canvas; the painter itself is wrapped in IgnorePointer in
// the canvas so nothing here eats taps meant for nodes.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
/// Which horizontal edge of its host node a port sits on.
@ -38,6 +40,14 @@ class EdgeSegment {
/// bytes=orange, ) instead of one uniform muted grey.
final Color? color;
/// Optional second colour for a sourcetarget gradient.
/// When both [color] and [colorEnd] are set, the painter
/// draws the wire with a linear shader running from
/// `color` at `from` to `colorEnd` at `to` operators
/// can read flow direction from the colour transition
/// alone. When `colorEnd` is null the wire stays solid.
final Color? colorEnd;
/// When true, the segment renders as marching dashes
/// driven by the painter's `phase` parameter — used to
/// show "data is flowing right now" for edges pointing at
@ -52,6 +62,7 @@ class EdgeSegment {
this.shortenFrom = true,
this.shortenTo = true,
this.color,
this.colorEnd,
this.animated = false,
});
}
@ -115,6 +126,19 @@ class EdgePainter extends CustomPainter {
final shortenedTo = Offset(seg.to.dx + toShift, seg.to.dy);
final path = _bezier(shortenedFrom, shortenedTo);
// Sourcetarget gradient: when both colours are set,
// build a linear shader running from the segment's
// `from` to its `to`. The wire's hue transitions along
// the bezier operators read direction from colour
// alone without needing arrow heads.
Shader? shader;
if (seg.colorEnd != null && !seg.animated) {
shader = ui.Gradient.linear(shortenedFrom, shortenedTo, [
color,
seg.colorEnd!,
]);
}
if (seg.animated) {
// Underlying muted line so the path's geometry stays
// visible between dash gaps.
@ -129,10 +153,14 @@ class EdgePainter extends CustomPainter {
_drawAnimatedDashes(canvas, path, color, strokeWidth);
} else {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
if (shader != null) {
paint.shader = shader;
} else {
paint.color = color;
}
canvas.drawPath(path, paint);
}
}

View file

@ -123,6 +123,21 @@ class _FlowCanvasState extends State<FlowCanvas>
// pattern button on the bottom-right canvas controls.
_CanvasPattern _pattern = _CanvasPattern.dots;
// Effective style for this build pass `widget.style`
// (or _styleOverride) clamped against MediaQuery's
// reduce-motion preference. Set at the start of every
// build so subsequent helpers (_buildSegments,
// _stepPositioned, etc.) read a single coherent style
// instead of separately querying the source + OS
// preference.
FaiEditorStyle _style = FaiEditorStyle.modern;
// Operator's runtime style override — set by the
// bottom-right Style sheet. When non-null, replaces
// `widget.style` for the lifetime of the canvas state.
// null = follow whatever the host passed.
FaiEditorStyle? _styleOverride;
// ModuleSpec cache: capability identifier declared
// inputs/outputs. Populated lazily on each build by
// walking the current graph's `step.use` values and
@ -239,6 +254,15 @@ class _FlowCanvasState extends State<FlowCanvas>
final theme = Theme.of(context);
final graph = widget.controller.graph;
final layout = widget.controller.layout;
// Clamp the host-supplied style against the OS reduce-
// motion preference. Operators on accessibility settings
// or kiosk-mode deployments get the same editor with
// GPU-heavy blur, gradients, and dash animation switched
// off. Helpers below read `_style` instead of `widget.style`
// so the effective shape is single-sourced.
_style = (_styleOverride ?? widget.style).clampedForA11y(
disableAnimations: MediaQuery.disableAnimationsOf(context),
);
// Best-effort lazy module-spec fetch so the per-field
// input/output ports paint on the next frame. Cheap when
// every spec is already cached; only triggers async work
@ -275,7 +299,7 @@ class _FlowCanvasState extends State<FlowCanvas>
// canvas depth; "flat" preset drops it for the older
// single-surface look (lower visual chrome, useful for
// low-spec terminals).
decoration: widget.style.canvasBackdrop == EditorCanvasBackdrop.gradient
decoration: _style.canvasBackdrop == EditorCanvasBackdrop.gradient
? BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
@ -502,6 +526,12 @@ class _FlowCanvasState extends State<FlowCanvas>
),
],
),
IconButton(
onPressed: _openStyleSheet,
icon: const Icon(Icons.tune, size: 18),
tooltip: 'Style',
visualDensity: VisualDensity.compact,
),
IconButton(
onPressed: _resetLayout,
icon: const Icon(Icons.dashboard_outlined, size: 18),
@ -680,7 +710,15 @@ class _FlowCanvasState extends State<FlowCanvas>
kind: kindForStep(step),
selected: selected,
status: status,
elevated: widget.style.nodeShadows,
elevated: _style.nodeShadows,
// Breathing-pulse on running steps when the active
// style allows flow animation. The canvas's existing
// _flowAnim ticks 0..1 during runs and is gated on
// the same accessibility / style preferences, so we
// can route it straight through.
pulse: _style.flowAnimation && status == FlowNodeStatus.running
? _flowAnim
: null,
onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
@ -867,19 +905,56 @@ 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;
// Type-aware wire colours at both endpoints. Inputs-
// endpoint side carries the declared input type's
// accent; step-output side reads the ModuleSpec when
// resolved so the wire's target colour matches the
// declared output type. With both ends typed, the
// painter draws a sourcetarget gradient operators
// read flow direction from colour alone, no arrow
// heads needed.
final theme2 = Theme.of(context);
Color? wireColorStart;
if (edge.fromKind == EdgeEndpointKind.inputs) {
final input = graph.inputs[edge.fromField];
if (input != null) {
wireColor = _typeAccent(input.type, Theme.of(context));
wireColorStart = _typeAccent(input.type, theme2);
}
} else if (edge.fromKind == EdgeEndpointKind.step) {
final fromStep = graph.steps.firstWhere(
(s) => s.id == edge.fromId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final fromSpec = _specForStep(fromStep);
if (fromSpec != null) {
final f = fromSpec.outputs.firstWhere(
(f) => f.name == edge.fromField,
orElse: () => const ModuleField(name: '', type: ''),
);
if (f.type.isNotEmpty) {
wireColorStart = _typeAccent(f.type, theme2);
}
}
}
Color? wireColorEnd;
// outputs endpoint has no declared type of its own
// it's a pass-through. The wire reads as the source
// type, so we leave wireColorEnd null and the painter
// collapses to a solid wire.
if (edge.toKind == EdgeEndpointKind.step) {
final toStep = graph.steps.firstWhere(
(s) => s.id == edge.toId,
orElse: () => const FlowStep(id: '__missing__', use: ''),
);
final toSpec = _specForStep(toStep);
if (toSpec != null) {
final f = toSpec.inputs.firstWhere(
(f) => f.name == edge.toField,
orElse: () => const ModuleField(name: '', type: ''),
);
if (f.type.isNotEmpty) {
wireColorEnd = _typeAccent(f.type, theme2);
}
}
}
// Edge is "live" when its target step is currently
@ -890,11 +965,19 @@ class _FlowCanvasState extends State<FlowCanvas>
? widget.controller.stepStatuses[edge.toId]
: null;
final animated =
widget.style.flowAnimation && targetStatus == StepRunStatus.running;
_style.flowAnimation && 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;
final color = highlight ? null : (wireColorStart ?? wireColorEnd);
final colorEnd = highlight ? null : (wireColorEnd ?? wireColorStart);
// Only attach the gradient end-colour when both sides
// resolved to a real type AND they differ. Same colour
// both ends solid wire (the painter's existing path).
final gradientEnd =
(color != null && colorEnd != null && color != colorEnd)
? colorEnd
: null;
out.add(
EdgeSegment(
from: from,
@ -903,6 +986,7 @@ class _FlowCanvasState extends State<FlowCanvas>
toSide: toSide,
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
color: color,
colorEnd: gradientEnd,
animated: animated,
),
);
@ -1139,6 +1223,116 @@ class _FlowCanvasState extends State<FlowCanvas>
..scaleByDouble(level, level, 1, 1);
}
/// Modal bottom sheet listing the four style toggles
/// (glass, gradient backdrop, flow animation, node
/// shadows). Edits are kept in `_styleOverride` for the
/// lifetime of the canvas; "Reset to host default" drops
/// the override and the host-supplied style wins again.
Future<void> _openStyleSheet() async {
// Seed from the current effective source (override or
// host) so toggles open in the right state.
final base = _styleOverride ?? widget.style;
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (ctx) {
var draft = base;
return StatefulBuilder(
builder: (ctx, setLocal) {
Widget tile({
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return SwitchListTile(
title: Text(title),
subtitle: Text(
subtitle,
style: Theme.of(ctx).textTheme.bodySmall,
),
value: value,
onChanged: (v) {
setLocal(() => onChanged(v));
},
);
}
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('Editor style'),
subtitle: const Text(
'Toggles apply to this canvas for the rest of the session.',
),
trailing: TextButton(
onPressed: () {
setState(() => _styleOverride = null);
Navigator.of(ctx).pop();
},
child: const Text('Reset to default'),
),
),
tile(
title: 'Frosted glass panels',
subtitle: 'BackdropFilter blur on the properties panel.',
value: draft.panelStyle == EditorPanelStyle.glass,
onChanged: (v) {
draft = draft.copyWith(
panelStyle: v
? EditorPanelStyle.glass
: EditorPanelStyle.solid,
);
setState(() => _styleOverride = draft);
},
),
tile(
title: 'Gradient canvas backdrop',
subtitle: 'Subtle diagonal gradient on the canvas.',
value:
draft.canvasBackdrop == EditorCanvasBackdrop.gradient,
onChanged: (v) {
draft = draft.copyWith(
canvasBackdrop: v
? EditorCanvasBackdrop.gradient
: EditorCanvasBackdrop.flat,
);
setState(() => _styleOverride = draft);
},
),
tile(
title: 'Flow animation',
subtitle:
'Marching dashes + breathing pulse during runs.',
value: draft.flowAnimation,
onChanged: (v) {
draft = draft.copyWith(flowAnimation: v);
setState(() => _styleOverride = draft);
},
),
tile(
title: 'Node shadows',
subtitle: 'Layered drop shadows under each step card.',
value: draft.nodeShadows,
onChanged: (v) {
draft = draft.copyWith(nodeShadows: v);
setState(() => _styleOverride = draft);
},
),
],
),
),
);
},
);
},
);
}
// --- Port overlays (drag handles for creating edges) ---
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {

View file

@ -13,6 +13,8 @@
// just outside the card border so edges can visually anchor
// to them.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../model/flow_graph.dart';
@ -170,6 +172,14 @@ class FlowNode extends StatelessWidget {
/// glance at the canvas and see what's running.
final FlowNodeStatus status;
/// 0..1 oscillating value driving the "breathing pulse"
/// glow on running steps. The canvas hooks this to its
/// flow-animation controller, so it's already gated on the
/// active style's `flowAnimation` setting + on whether any
/// step is actually running. When null (or status != running),
/// no pulse is rendered.
final Listenable? pulse;
const FlowNode({
super.key,
required this.id,
@ -189,6 +199,7 @@ class FlowNode extends StatelessWidget {
this.onDragEnd,
this.onContextMenu,
this.elevated = true,
this.pulse,
});
@override
@ -199,66 +210,109 @@ class FlowNode extends StatelessWidget {
inputPortLabels.length,
outputPortLabels.length,
);
final cardWidget = _card(theme, accent, height);
// Breathing-pulse glow on running steps. The pulse
// listenable is the canvas's own flow-animation
// controller (0..1), so a single host-side tick drives
// every running step's halo in sync. Skipped when no
// animation source is wired or the step isn't running.
if (pulse != null && status == FlowNodeStatus.running) {
return SizedBox(
width: NodeGeometry.width,
height: height,
child: AnimatedBuilder(
animation: pulse!,
builder: (context, child) {
// Derive a 0..1 breath value from the controller.
// Use a sine wave so the glow eases in + out
// rather than ramping linearly reads as
// "breathing" instead of "flickering".
final t = (pulse! is Animation<double>)
? (pulse! as Animation<double>).value
: 0.0;
final breath = 0.5 + 0.5 * math.sin(t * 2 * math.pi);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.18 + 0.22 * breath),
blurRadius: 20 + 8 * breath,
spreadRadius: 0.5 + 1.5 * breath,
),
],
),
child: child,
);
},
child: cardWidget,
),
);
}
return SizedBox(
width: NodeGeometry.width,
height: height,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
onSecondaryTapDown: onContextMenu == null
? null
: (details) => onContextMenu!(details.globalPosition),
// Long-press is the trackpad fallback for the context
// menu macOS trackpad users whose "two-finger click"
// isn't mapped to secondary still get the same menu.
onLongPressStart: onContextMenu == null
? null
: (details) => onContextMenu!(details.globalPosition),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(
color: selected ? accent : theme.dividerColor,
width: selected ? 2 : 1,
),
// Layered shadows give nodes real depth: a sharp
// inner shadow + a softer outer halo. Selected
// nodes get a coloured accent halo on top so the
// selection state reads from across the canvas.
// When the active style turns elevation off, we
// drop the shadows entirely for a flat-design look.
boxShadow: elevated
? [
BoxShadow(
color: Colors.black.withValues(
alpha: selected ? 0.32 : 0.18,
),
blurRadius: selected ? 18 : 10,
offset: const Offset(0, 4),
),
if (selected)
BoxShadow(
color: accent.withValues(alpha: 0.30),
blurRadius: 24,
spreadRadius: 1,
),
]
: null,
child: cardWidget,
);
}
Widget _card(ThemeData theme, Color accent, double height) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
onSecondaryTapDown: onContextMenu == null
? null
: (details) => onContextMenu!(details.globalPosition),
// Long-press is the trackpad fallback for the context
// menu macOS trackpad users whose "two-finger click"
// isn't mapped to secondary still get the same menu.
onLongPressStart: onContextMenu == null
? null
: (details) => onContextMenu!(details.globalPosition),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.md),
border: Border.all(
color: selected ? accent : theme.dividerColor,
width: selected ? 2 : 1,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(theme, accent),
Expanded(child: _body(theme)),
],
),
// Layered shadows give nodes real depth: a sharp
// inner shadow + a softer outer halo. Selected
// nodes get a coloured accent halo on top so the
// selection state reads from across the canvas.
// When the active style turns elevation off, we
// drop the shadows entirely for a flat-design look.
boxShadow: elevated
? [
BoxShadow(
color: Colors.black.withValues(
alpha: selected ? 0.32 : 0.18,
),
blurRadius: selected ? 18 : 10,
offset: const Offset(0, 4),
),
if (selected)
BoxShadow(
color: accent.withValues(alpha: 0.30),
blurRadius: 24,
spreadRadius: 1,
),
]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(theme, accent),
Expanded(child: _body(theme)),
],
),
),
),

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.9.0
version: 0.10.0
publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor