reclaim/app/lib/widgets/delta_mark.dart
flemming-it 136d3b48c6 perf(ui): stop the nav-rail brand pulse to fix web scroll jank
The DeltaMark ran an endless 9s breathing animation (60fps repaint)
inside the persistent nav rail, so it repainted on every scroll
frame and stuttered scrolling on Flutter web. Make the rail mark
static (animated: false) and isolate the animated variant (landing
page) behind a RepaintBoundary.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-19 02:25:38 +02:00

111 lines
3 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/reclaim_tokens.dart';
/// The F∆I / Ch∆In brand mark: an upward triangle (the `∆`) with
/// a petrol "eye" at the centre. Geometry mirrors fai_web's
/// Logo.astro — the triangle IS the "A"; the eye-dot IS the dot
/// of the "i".
///
/// Subtle 9s breathing pulse on the eye (matches fai_web's
/// `.eye-pulse`). Set [animated] to false for static contexts
/// (icons, screenshots).
class DeltaMark extends StatefulWidget {
const DeltaMark({super.key, this.size = 64, this.animated = true});
final double size;
final bool animated;
@override
State<DeltaMark> createState() => _DeltaMarkState();
}
class _DeltaMarkState extends State<DeltaMark>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 9),
);
if (widget.animated) {
_controller.repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Static mark: no controller listening, paint once.
if (!widget.animated) {
return CustomPaint(
size: Size.square(widget.size),
painter: _DeltaPainter(pulse: 1.0),
);
}
// Animated: isolate the per-frame repaint in its own layer so
// the breathing pulse never invalidates surrounding content
// (web scroll stays smooth).
return RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final t = _controller.value;
// Breathe: scale 1 → 1.28 → 1 over the cycle.
final pulse =
1.0 + 0.28 * (1 - (math.cos(t * 2 * math.pi).abs()));
return CustomPaint(
size: Size.square(widget.size),
painter: _DeltaPainter(pulse: pulse),
);
},
),
);
}
}
class _DeltaPainter extends CustomPainter {
_DeltaPainter({required this.pulse});
final double pulse;
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
// Upward triangle, geometric centre.
final stroke = Paint()
..color = const Color(0xFFE9E7E2)
..style = PaintingStyle.stroke
..strokeWidth = w * 0.04
..strokeJoin = StrokeJoin.miter;
final inset = w * 0.08;
final tri = Path()
..moveTo(w / 2, inset)
..lineTo(w - inset, h - inset)
..lineTo(inset, h - inset)
..close();
canvas.drawPath(tri, stroke);
// Eye — petrol fill, centred on the triangle's centroid.
final eyeCentre = Offset(w / 2, inset + (h - 2 * inset) * 2 / 3);
final eyeR = w * 0.06 * pulse;
final eye = Paint()..color = ReclaimColors.signal;
canvas.drawCircle(eyeCentre, eyeR, eye);
}
@override
bool shouldRepaint(covariant _DeltaPainter old) => old.pulse != pulse;
}