restyle activity card as a holder container; background-toggle collapse (T-266)

Extracts a shared ClideHolderCard primitive (consumed next by T-264) that
renders a folded run as one framed container wrapping its sub-cards:

- The whole holder background is the collapse toggle — a gesture target
  behind the children that only fires for hits the children don't consume.
  Each sub-card opaquely absorbs its own bounds, so a card tap (and its
  copy button) interacts with the card, never the holder; selection drags
  pass through. This ends the scroll race: while a run tail-follows, a
  click on whatever background is in view collapses it, no top header to
  reach.
- A focusable caret keeps the control keyboard/AT reachable (D-78); the
  collapsed ticker + step count are preserved.
- _ActivityCard becomes a thin stateless adopter of the primitive.

Tests: ticker/expand, background-toggle, child-tap-not-hijacked, copy
still works, keyboard Activate path; golden for collapsed + expanded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:04:38 +02:00
co-authored by Claude Opus 4.8
parent a7a7b04a05
commit 75f7d1ca12
8 changed files with 478 additions and 79 deletions
+132
View File
@@ -0,0 +1,132 @@
/// Widget tests for the shared [ClideHolderCard] container primitive (T-266):
/// collapsed ticker, background-as-toggle, child taps not hijacked, the copy
/// control still works, and the keyboard/AT collapse path.
library;
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/holder_card.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async {
f = await KernelFixture.create();
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (_) async => null);
});
tearDown(() {
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null);
f.dispose();
});
// A tight, positioned tree so the holder's Row/Expanded get a bounded width
// and a deterministic on-screen rect for coordinate hit-testing.
Future<void> pump(WidgetTester tester, {List<Widget>? children, String summary = 'latest step', bool expanded = false}) async {
await tester.pumpWidget(harness(
f,
Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: ClideHolderCard(
collapsedSummary: summary,
stepLabel: '2 steps',
initiallyExpanded: expanded,
children: children ??
const [
ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'step',
body: Text('child body', textDirection: TextDirection.ltr),
),
],
),
),
),
));
await tester.pump();
}
testWidgets('collapsed shows the ticker summary + step count; tap expands', (tester) async {
await pump(tester);
expect(find.text('latest step'), findsOneWidget);
expect(find.text('2 steps'), findsOneWidget);
expect(find.bySemanticsLabel('Activity, 2 steps, collapsed'), findsOneWidget);
expect(find.text('child body'), findsNothing); // children hidden while collapsed
await tester.tap(find.bySemanticsLabel('Activity, 2 steps, collapsed'));
await tester.pump();
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
expect(find.text('child body'), findsOneWidget);
});
testWidgets('a tap on the holder BACKGROUND (gutter) collapses it', (tester) async {
await pump(tester, expanded: true);
expect(find.text('child body'), findsOneWidget); // expanded
// The left gutter (x = left+4) is holder background — children start at
// left+10 — and below the header, so this hits the background toggle.
final r = tester.getRect(find.byType(ClideHolderCard));
await tester.tapAt(Offset(r.left + 4, r.center.dy));
await tester.pump();
expect(find.text('child body'), findsNothing); // collapsed
expect(find.bySemanticsLabel('Activity, 2 steps, collapsed'), findsOneWidget);
});
testWidgets('a tap on a child sub-card does NOT toggle the holder', (tester) async {
await pump(tester, expanded: true);
expect(find.text('child body'), findsOneWidget);
// Tapping the child's body must be absorbed by the child, not bubble to the
// holder background toggle.
await tester.tap(find.text('child body'));
await tester.pump();
expect(find.text('child body'), findsOneWidget); // still expanded
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
});
testWidgets('a child copy button still copies — not swallowed by the holder', (tester) async {
await pump(tester, expanded: true, children: const [
ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFFFFFFFF),
label: 'step',
copyText: 'copied from a held card',
body: Text('child body', textDirection: TextDirection.ltr),
),
]);
// Hover the child to reveal its copy action, then tap it.
final g = await tester.createGesture(kind: PointerDeviceKind.mouse);
await g.addPointer(location: Offset.zero);
addTearDown(() => g.removePointer());
await g.moveTo(tester.getCenter(find.byType(ConversationCard)));
await tester.pump();
await tester.tap(find.text('copy'));
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
expect(f.services.clipboard.readAs<String>(), 'copied from a held card');
// And the holder stayed expanded (the copy tap wasn't a background toggle).
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
});
testWidgets('the explicit control is keyboard-focusable and toggles on Activate (a11y)', (tester) async {
await pump(tester); // collapsed; the ticker row is the control
final focusWidget = tester.widget<Focus>(
find.ancestor(of: find.text('latest step'), matching: find.byType(Focus)).first,
);
focusWidget.focusNode!.requestFocus();
await tester.pump();
expect(focusWidget.focusNode!.hasFocus, isTrue);
Actions.invoke(focusWidget.focusNode!.context!, const ActivateIntent());
await tester.pump();
expect(find.bySemanticsLabel('Activity, 2 steps, expanded'), findsOneWidget);
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,66 @@
import 'package:alchemist/alchemist.dart';
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/holder_card.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/kernel_fixture.dart';
import '../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
goldenTest(
'ClideHolderCard (T-266): collapsed ticker + expanded container of sub-cards',
fileName: 'holder_card',
builder: () => GoldenTestGroup(
columns: 1,
children: [
GoldenTestScenario(
name: 'collapsed (ticker + step count)',
child: _wrap(
f,
const ClideHolderCard(
collapsedSummary: 'Bash ls -la',
stepLabel: '3 steps',
children: [],
),
),
),
GoldenTestScenario(
name: 'expanded (framed container wrapping sub-cards)',
child: _wrap(
f,
const ClideHolderCard(
collapsedSummary: 'Bash ls -la',
stepLabel: '3 steps',
initiallyExpanded: true,
children: [
ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFF4C9AFF),
label: 'Bash',
body: Text('ls -la', textDirection: TextDirection.ltr),
),
ConversationCard(
variant: ConversationCardVariant.bordered,
accent: Color(0xFF4C9AFF),
label: 'Read',
body: Text('/lib/main.dart', textDirection: TextDirection.ltr),
),
],
),
),
),
],
),
);
}
Widget _wrap(KernelFixture f, Widget child) => SizedBox(
width: 360,
child: harness(f, child),
);