ClideMarquee honours reduced motion (MediaQuery.disableAnimations) (T-284)

The status-bar footer marquee drove a raw Ticker whenever its content
overflowed, ignoring MediaQuery.disableAnimations — unlike the turn indicator
(T-273), which stops on the same flag. Two costs: reduced-motion users still
got the scrolling footer, and the perpetual ticker was the historical
pumpAndSettle-hang culprit (its tests cope by only ever pump()-ing).

Unify on the one mechanism: read disableAnimations in didChangeDependencies
(as running_indicator does) and gate the ticker on it via _syncTicker(). Under
reduced motion the marquee never starts the ticker (stops + resets if running)
and renders the child statically (clipped, no looped copy). Toggling the flag
at runtime starts/stops the scroll.

Tests: reduced-motion marquee does not scroll and pumpAndSettle completes (no
hang); flipping the flag off lets an overflowing marquee scroll again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:57:25 +02:00
co-authored by Claude Opus 4.8
parent 5241876871
commit d5ec85932d
5 changed files with 104 additions and 4 deletions
@@ -871,3 +871,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-279', 'status', 'ready', 'in_progress', NULL, '2026-06-08 13:49:32', '2026-06-08 13:49:32', '2026-06-08 13:49:32', NULL, 'e6593b168ebb31f66ca19fe3e36557cb', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-279', 'status', 'ready', 'in_progress', NULL, '2026-06-08 13:49:32', '2026-06-08 13:49:32', '2026-06-08 13:49:32', NULL, 'e6593b168ebb31f66ca19fe3e36557cb', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'backlog', 'ready', NULL, '2026-06-08 14:12:26', '2026-06-08 14:12:26', '2026-06-08 14:12:26', NULL, 'a6cea52d7e3e1d0881e6330f1d9799bd', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-275', 'status', 'backlog', 'ready', NULL, '2026-06-08 14:12:26', '2026-06-08 14:12:26', '2026-06-08 14:12:26', NULL, 'a6cea52d7e3e1d0881e6330f1d9799bd', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-279', 'status', 'in_progress', 'done', NULL, '2026-06-08 14:12:46', '2026-06-08 14:12:46', '2026-06-08 14:12:46', NULL, '86c50eb88cf56b7e9e9914dcab61b9c3', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-279', 'status', 'in_progress', 'done', NULL, '2026-06-08 14:12:46', '2026-06-08 14:12:46', '2026-06-08 14:12:46', NULL, '86c50eb88cf56b7e9e9914dcab61b9c3', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-284', 'status', 'backlog', 'in_progress', NULL, '2026-06-08 14:55:51', '2026-06-08 14:55:51', '2026-06-08 14:55:51', NULL, '088102bf2d6d9337db66c82fd7b17922', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-284', 'status', 'in_progress', 'done', NULL, '2026-06-08 14:57:15', '2026-06-08 14:57:15', '2026-06-08 14:57:15', NULL, '7f2c5be1e2bb330dac14007350d6dec1', 1) ON CONFLICT(hash) DO NOTHING;
+39
View File
@@ -2189,3 +2189,42 @@ Scope notes:
- Handle unknown/missing ids gracefully (render as plain text or a dead-link tooltip, no crash). - Handle unknown/missing ids gracefully (render as plain text or a dead-link tooltip, no crash).
Tests: linkifier unit tests (matches T/D/Q/R, ignores code spans, ignores bare words like ''T-shirt''); a widget/integration test that clicking a ref dispatches the reader-open.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-08 10:21:20', '2026-06-08 14:12:46', NULL, 'e9aba50ab6a83dd280b3e4a375782dab', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); Tests: linkifier unit tests (matches T/D/Q/R, ignores code spans, ignores bare words like ''T-shirt''); a widget/integration test that clicking a ref dispatches the reader-open.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-08 10:21:20', '2026-06-08 14:12:46', NULL, 'e9aba50ab6a83dd280b3e4a375782dab', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-284', 'bug', 'T-276', 'ClideMarquee ignores MediaQuery.disableAnimations (reduced-motion + pumpAndSettle hang)', 'Two animation mechanisms exist and are inconsistent. The turn/running indicator (running_indicator.dart, T-273) gates its AnimationController on MediaQuery.disableAnimations and stops when reduced-motion is set. ClideMarquee (clide_marquee.dart, the status-bar footer scroll, T-150/T-160) drives a raw Ticker whenever its content overflows, with NO disableAnimations check and no flag.
Two costs:
1. Accessibility — a reduced-motion user still gets the scrolling footer; the marquee ignores the OS reduce-motion setting the turn indicator honors.
2. Testing — the marquee''s perpetual Ticker is the historical pumpAndSettle-hang culprit; its own tests cope by only ever calling pump()/pump(Duration), never pumpAndSettle.
Fix: unify on the one mechanism — make ClideMarquee honor MediaQuery.disableAnimations. When reduced motion is set, don''t start the ticker (stop + reset offset to 0 if running), and render the child statically (clipped) instead of looping. Read the flag in didChangeDependencies like running_indicator does. Then both animations obey one flag, the marquee becomes reduced-motion-correct, and it stops being a pumpAndSettle landmine.
Acceptance:
1. With MediaQuery(disableAnimations: true), an overflowing ClideMarquee does not scroll and pumpAndSettle completes (no hang).
2. With the flag off, overflow still scrolls as today.
3. Toggling the flag at runtime starts/stops the scroll.
4. Test covering the reduced-motion (no-scroll, settles) case.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-08 14:55:43', '2026-06-08 14:55:43', NULL, 'c347b735d788282c75fdb183eff05806', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-284', 'bug', 'T-276', 'ClideMarquee ignores MediaQuery.disableAnimations (reduced-motion + pumpAndSettle hang)', 'Two animation mechanisms exist and are inconsistent. The turn/running indicator (running_indicator.dart, T-273) gates its AnimationController on MediaQuery.disableAnimations and stops when reduced-motion is set. ClideMarquee (clide_marquee.dart, the status-bar footer scroll, T-150/T-160) drives a raw Ticker whenever its content overflows, with NO disableAnimations check and no flag.
Two costs:
1. Accessibility — a reduced-motion user still gets the scrolling footer; the marquee ignores the OS reduce-motion setting the turn indicator honors.
2. Testing — the marquee''s perpetual Ticker is the historical pumpAndSettle-hang culprit; its own tests cope by only ever calling pump()/pump(Duration), never pumpAndSettle.
Fix: unify on the one mechanism — make ClideMarquee honor MediaQuery.disableAnimations. When reduced motion is set, don''t start the ticker (stop + reset offset to 0 if running), and render the child statically (clipped) instead of looping. Read the flag in didChangeDependencies like running_indicator does. Then both animations obey one flag, the marquee becomes reduced-motion-correct, and it stops being a pumpAndSettle landmine.
Acceptance:
1. With MediaQuery(disableAnimations: true), an overflowing ClideMarquee does not scroll and pumpAndSettle completes (no hang).
2. With the flag off, overflow still scrolls as today.
3. Toggling the flag at runtime starts/stops the scroll.
4. Test covering the reduced-motion (no-scroll, settles) case.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-08 14:55:43', '2026-06-08 14:55:51', NULL, '0a74870ce1d2e452c853fc42ba803919', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-284', 'bug', 'T-276', 'ClideMarquee ignores MediaQuery.disableAnimations (reduced-motion + pumpAndSettle hang)', 'Two animation mechanisms exist and are inconsistent. The turn/running indicator (running_indicator.dart, T-273) gates its AnimationController on MediaQuery.disableAnimations and stops when reduced-motion is set. ClideMarquee (clide_marquee.dart, the status-bar footer scroll, T-150/T-160) drives a raw Ticker whenever its content overflows, with NO disableAnimations check and no flag.
Two costs:
1. Accessibility — a reduced-motion user still gets the scrolling footer; the marquee ignores the OS reduce-motion setting the turn indicator honors.
2. Testing — the marquee''s perpetual Ticker is the historical pumpAndSettle-hang culprit; its own tests cope by only ever calling pump()/pump(Duration), never pumpAndSettle.
Fix: unify on the one mechanism — make ClideMarquee honor MediaQuery.disableAnimations. When reduced motion is set, don''t start the ticker (stop + reset offset to 0 if running), and render the child statically (clipped) instead of looping. Read the flag in didChangeDependencies like running_indicator does. Then both animations obey one flag, the marquee becomes reduced-motion-correct, and it stops being a pumpAndSettle landmine.
Acceptance:
1. With MediaQuery(disableAnimations: true), an overflowing ClideMarquee does not scroll and pumpAndSettle completes (no hang).
2. With the flag off, overflow still scrolls as today.
3. Toggling the flag at runtime starts/stops the scroll.
4. Test covering the reduced-motion (no-scroll, settles) case.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 14:55:43', '2026-06-08 14:57:15', NULL, '31d8fd91f6d556a68d35c7d25410d7ec', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+6
View File
@@ -198,6 +198,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed ### Fixed
- The status-bar footer marquee now **honours reduced motion**: when the OS
reduce-motion setting (`MediaQuery.disableAnimations`) is on, a long status
line no longer scrolls — it renders statically (clipped) — matching the turn
indicator, which already obeyed the flag. Unifies the two animations on one
mechanism and removes a `pumpAndSettle` hang the perpetual ticker caused.
(T-284)
- Switching the workspace in place (Open Project/Folder) now rebinds the Claude - Switching the workspace in place (Open Project/Folder) now rebinds the Claude
pane to the new repo's session instead of keeping the previous repo's pane to the new repo's session instead of keeping the previous repo's
conversation, and drops the old repo's secondary tabs. Separate windows were conversation, and drops the old repo's secondary tabs. Separate windows were
+27 -4
View File
@@ -39,6 +39,12 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
double _offset = 0; double _offset = 0;
Duration _last = Duration.zero; Duration _last = Duration.zero;
/// Honour reduced motion (T-284): the same `MediaQuery.disableAnimations`
/// flag the running indicator gates on (T-273). When set, the marquee never
/// scrolls — it shows the child statically (clipped) — so reduced-motion
/// users get no motion and `pumpAndSettle` isn't wedged by a perpetual ticker.
bool _reduced = false;
bool get _overflow => _contentWidth > _viewportWidth + 0.5; bool get _overflow => _contentWidth > _viewportWidth + 0.5;
@override @override
@@ -47,6 +53,16 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
final reduced = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
if (reduced != _reduced) {
setState(() => _reduced = reduced);
_syncTicker();
}
}
@override @override
void didUpdateWidget(ClideMarquee old) { void didUpdateWidget(ClideMarquee old) {
super.didUpdateWidget(old); super.didUpdateWidget(old);
@@ -59,10 +75,17 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
if ((w - _contentWidth).abs() > 0.5) { if ((w - _contentWidth).abs() > 0.5) {
setState(() => _contentWidth = w); setState(() => _contentWidth = w);
} }
if (_overflow && !_ticker.isActive) { _syncTicker();
}
/// Start the scroll ticker only when the content overflows AND motion is
/// allowed; otherwise stop it and reset to the start.
void _syncTicker() {
final shouldRun = _overflow && !_reduced;
if (shouldRun && !_ticker.isActive) {
_last = Duration.zero; _last = Duration.zero;
_ticker.start(); _ticker.start();
} else if (!_overflow && _ticker.isActive) { } else if (!shouldRun && _ticker.isActive) {
_ticker.stop(); _ticker.stop();
_offset = 0; _offset = 0;
if (_scroll.hasClients) _scroll.jumpTo(0); if (_scroll.hasClients) _scroll.jumpTo(0);
@@ -70,7 +93,7 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
} }
void _tick(Duration elapsed) { void _tick(Duration elapsed) {
if (!_overflow || !_scroll.hasClients) return; if (!_overflow || _reduced || !_scroll.hasClients) return;
final dt = _last == Duration.zero ? 0.0 : (elapsed - _last).inMicroseconds / 1e6; final dt = _last == Duration.zero ? 0.0 : (elapsed - _last).inMicroseconds / 1e6;
_last = elapsed; _last = elapsed;
final span = _contentWidth + widget.gap; final span = _contentWidth + widget.gap;
@@ -101,7 +124,7 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
KeyedSubtree(key: _childKey, child: widget.child), KeyedSubtree(key: _childKey, child: widget.child),
if (_overflow) ...[SizedBox(width: widget.gap), widget.child], if (_overflow && !_reduced) ...[SizedBox(width: widget.gap), widget.child],
], ],
), ),
); );
+30
View File
@@ -13,6 +13,11 @@ Widget _boxed(double width, Widget child) => Directionality(
), ),
); );
Widget _reducedMotion(double width, Widget child) => MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: _boxed(width, child),
);
void main() { void main() {
testWidgets('shows the child statically when it fits', (tester) async { testWidgets('shows the child statically when it fits', (tester) async {
await tester.pumpWidget(_boxed(300, const ClideMarquee(child: Text('short')))); await tester.pumpWidget(_boxed(300, const ClideMarquee(child: Text('short'))));
@@ -29,4 +34,29 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox()); // dispose → stop ticker await tester.pumpWidget(const SizedBox()); // dispose → stop ticker
}); });
testWidgets('reduced motion (disableAnimations) does not scroll; pumpAndSettle completes (T-284)', (tester) async {
await tester.pumpWidget(_reducedMotion(40, const ClideMarquee(child: Text('a long status line that overflows the slot'))));
// The ticker must never start, so the frame queue is quiescent — if the
// marquee still ran its ticker, this would hang for the 10-minute default.
await tester.pumpAndSettle();
expect(find.text('a long status line that overflows the slot'), findsWidgets);
// No looped second copy is built under reduced motion (single rendered copy).
expect(find.text('a long status line that overflows the slot'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
testWidgets('toggling disableAnimations off lets an overflowing marquee scroll again (T-284)', (tester) async {
const child = ClideMarquee(child: Text('a long status line that overflows the slot'));
await tester.pumpWidget(_reducedMotion(40, child));
await tester.pumpAndSettle(); // frozen, settles
// Flip the flag off → ticker should start; the looped copy reappears.
await tester.pumpWidget(_boxed(40, child));
await tester.pump(); // measure
await tester.pump(const Duration(milliseconds: 100)); // advance the ticker
expect(find.text('a long status line that overflows the slot'), findsWidgets);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox()); // dispose → stop ticker
});
} }