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

@ -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* {