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:
@@ -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-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-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;
|
||||
|
||||
@@ -2189,3 +2189,42 @@ Scope notes:
|
||||
- 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);
|
||||
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);
|
||||
|
||||
@@ -198,6 +198,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
@@ -39,6 +39,12 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
|
||||
double _offset = 0;
|
||||
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;
|
||||
|
||||
@override
|
||||
@@ -47,6 +53,16 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final reduced = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
|
||||
if (reduced != _reduced) {
|
||||
setState(() => _reduced = reduced);
|
||||
_syncTicker();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ClideMarquee old) {
|
||||
super.didUpdateWidget(old);
|
||||
@@ -59,10 +75,17 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
|
||||
if ((w - _contentWidth).abs() > 0.5) {
|
||||
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;
|
||||
_ticker.start();
|
||||
} else if (!_overflow && _ticker.isActive) {
|
||||
} else if (!shouldRun && _ticker.isActive) {
|
||||
_ticker.stop();
|
||||
_offset = 0;
|
||||
if (_scroll.hasClients) _scroll.jumpTo(0);
|
||||
@@ -70,7 +93,7 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
|
||||
}
|
||||
|
||||
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;
|
||||
_last = elapsed;
|
||||
final span = _contentWidth + widget.gap;
|
||||
@@ -101,7 +124,7 @@ class _ClideMarqueeState extends State<ClideMarquee> with SingleTickerProviderSt
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
KeyedSubtree(key: _childKey, child: widget.child),
|
||||
if (_overflow) ...[SizedBox(width: widget.gap), widget.child],
|
||||
if (_overflow && !_reduced) ...[SizedBox(width: widget.gap), widget.child],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
testWidgets('shows the child statically when it fits', (tester) async {
|
||||
await tester.pumpWidget(_boxed(300, const ClideMarquee(child: Text('short'))));
|
||||
@@ -29,4 +34,29 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user