From a71f654162db6d639a5dfb332532c2cf35301217 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 1 Jun 2026 22:55:18 +0200 Subject: [PATCH] feat(editor): reduce-motion clamp + edge gradient + breathing pulse + style sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/src/editor_style.dart | 23 ++++ lib/src/flow_editor_page.dart | 7 +- lib/src/widgets/edge_painter.dart | 30 +++- lib/src/widgets/flow_canvas.dart | 222 ++++++++++++++++++++++++++++-- lib/src/widgets/flow_node.dart | 166 ++++++++++++++-------- pubspec.yaml | 2 +- 6 files changed, 377 insertions(+), 73 deletions(-) diff --git a/lib/src/editor_style.dart b/lib/src/editor_style.dart index e1f3985..8e1e136 100644 --- a/lib/src/editor_style.dart +++ b/lib/src/editor_style.dart @@ -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, + ); + } } diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 71bcdf3..24664d0 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -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; diff --git a/lib/src/widgets/edge_painter.dart b/lib/src/widgets/edge_painter.dart index 789691d..bfbd2ae 100644 --- a/lib/src/widgets/edge_painter.dart +++ b/lib/src/widgets/edge_painter.dart @@ -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 source→target 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); + // Source→target 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); } } diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 89f459a..1ba537d 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -123,6 +123,21 @@ class _FlowCanvasState extends State // 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 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 // 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 ), ], ), + 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 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 _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 source→target 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 ? 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 toSide: toSide, accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal, color: color, + colorEnd: gradientEnd, animated: animated, ), ); @@ -1139,6 +1223,116 @@ class _FlowCanvasState extends State ..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 _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( + 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 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 _portOverlays(FlowGraph graph, FlowLayout layout) sync* { diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index a0a345f..d0622ca 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -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) + ? (pulse! as Animation).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)), + ], ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index adb748e..c0b7be4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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