feat(editor): graph-pulse on errors + store-aware install / add-source

Three connected improvements to the analyzer-driven diagnostics:

  - **Pulsing halo on broken graph nodes**. Every step / pseudo-
    node (inputs / outputs) carrying an analyzer issue now
    breathes a red (error) or amber (warning) halo on the graph
    tab — operator sees the problem on the canvas without
    flipping to text. Honours prefers-reduced-motion: reduce-
    motion users get a static halo at the same intensity. The
    canvas reads severity via a new `stepIssueSeverity` map on
    FlowEditorController; pseudo-nodes use `__inputs__` /
    `__outputs__` sentinel ids.

  - **Store-aware install button**. The analyzer now takes a
    second closure, `storeCapabilities`, listing what the public
    store can install. Unknown-capability issues only carry the
    "Install …" quick-fix when the bare cap is in that list;
    otherwise the issue carries an "Add source for …" fix
    instead. Resolves the asymmetry the operator reported: the
    Store didn't show `htw.digiscout/onet.lookup` but the editor
    happily offered to install it (and would have failed). The
    install path no longer lies about itself.

  - **Did-you-mean suggestion**. When the unknown cap is within
    edit-distance two of an installed or store cap (different
    spelling — distance-0 stays hidden because that's an install
    case, not a typo), the analyzer emits a `ReplaceLineValueFix`
    suggesting the closest match. Preserves the version
    constraint by reusing the installed spec when present
    (e.g. `text.echi@^0.1` → `text.echo@^0.1`).

New public surface:

  - `AddModuleSourceFix` + `AddModuleSourceCallback`
  - `FlowEditorPage.storeCapabilities` + `onAddModuleSource`
  - `FlowAnalyzer.stepSeverity` + the `kInputsNodeId` /
    `kOutputsNodeId` sentinels
  - `FlowIssueSeverity` enum + `FlowNode.issueSeverity`

Tests:
  - Install fix only when in store
  - AddModuleSourceFix as fallback when not in store
  - Did-you-mean replaces install when a near miss exists
  - stepSeverity populated for both step ids and pseudo-nodes

All 36 editor tests green. Bumped to 0.18.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-09 00:21:48 +02:00
parent f43c1ac6cf
commit efdfa7dd79
10 changed files with 497 additions and 45 deletions

View file

@ -154,6 +154,12 @@ enum NodeVisualKind { module, approval, inputs, outputs }
/// endpoint OUT to downstream steps).
enum NodePortSide { left, right }
/// Severity tag passed to [FlowNode.issueSeverity]. Mirrors
/// flutter_code_editor's `IssueType` but lives in the editor
/// package so flow_node.dart doesn't pull the code-editor
/// transitively into widget tests that don't need it.
enum FlowIssueSeverity { error, warning }
class FlowNode extends StatelessWidget {
final String id;
final String title;
@ -224,6 +230,14 @@ class FlowNode extends StatelessWidget {
/// no pulse is rendered.
final Listenable? pulse;
/// When non-null, the node renders a permanently pulsing
/// halo in the appropriate severity colour red for an
/// analyzer-flagged error, amber for a warning so the
/// operator scanning the canvas immediately sees which step
/// the YAML linter is unhappy about, without switching to
/// the text tab.
final FlowIssueSeverity? issueSeverity;
const FlowNode({
super.key,
required this.id,
@ -244,6 +258,7 @@ class FlowNode extends StatelessWidget {
this.onContextMenu,
this.elevated = true,
this.pulse,
this.issueSeverity,
this.width = NodeGeometry.width,
});
@ -268,10 +283,6 @@ class FlowNode extends StatelessWidget {
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;
@ -294,6 +305,19 @@ class FlowNode extends StatelessWidget {
),
);
}
if (issueSeverity != null) {
// Always-on breathing halo in the severity colour. Owns
// its own AnimationController so it ticks even when no
// step is running (no shared pulse listenable upstream).
return SizedBox(
width: width,
height: height,
child: _IssueHalo(
severity: issueSeverity!,
child: cardWidget,
),
);
}
return SizedBox(width: width, height: height, child: cardWidget);
}
@ -592,3 +616,80 @@ NodeVisualKind kindForStep(FlowStep step) {
if (step.isApproval) return NodeVisualKind.approval;
return NodeVisualKind.module;
}
/// Permanently breathing halo painted around a node carrying
/// an analyzer issue. Owns its own AnimationController so it
/// ticks regardless of the canvas's run-state. Halo colour
/// matches severity error red or warning amber. Honours
/// `MediaQuery.disableAnimations` so reduce-motion operators
/// still get a static halo without the breathing oscillation.
class _IssueHalo extends StatefulWidget {
final FlowIssueSeverity severity;
final Widget child;
const _IssueHalo({required this.severity, required this.child});
@override
State<_IssueHalo> createState() => _IssueHaloState();
}
class _IssueHaloState extends State<_IssueHalo>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Color _tone(BuildContext context) {
final theme = Theme.of(context);
return widget.severity == FlowIssueSeverity.error
? theme.colorScheme.error
: const Color(0xFFEF6C00);
}
@override
Widget build(BuildContext context) {
final tone = _tone(context);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
if (reduceMotion) {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: [
BoxShadow(
color: tone.withValues(alpha: 0.35),
blurRadius: 18,
spreadRadius: 1.5,
),
],
),
child: widget.child,
);
}
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final breath = 0.5 + 0.5 * math.sin(_controller.value * 2 * math.pi);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FaiRadius.md),
boxShadow: [
BoxShadow(
color: tone.withValues(alpha: 0.22 + 0.30 * breath),
blurRadius: 16 + 8 * breath,
spreadRadius: 0.5 + 2.0 * breath,
),
],
),
child: child,
);
},
child: widget.child,
);
}
}