add ClideCollapserCard primitive + shared card metrics (T-305)

First slice of the conversation-panel card unification. Adds the
category-3 collapser primitive (lib/widgets/): a list of 1..N inner item
cards, collapsed ticker <-> expanded framed inner canvas, with the agreed
chrome — color drives the border + chevron/label tint, a fixed-width
counter slot, the status icon hard against the right edge, chevron hard
against the left edge, background + caret toggle (D-78 tail-follow). The
aggregate status/count/echoed-title are caller-computed, so the widget
stays free of conversation semantics; inner items keep their own per-item
status.

Also adds clide_card_metrics.dart — shared spacing constants (gap, radius,
header padding, counter slot width) for all three card categories.

Not yet wired into the stream (no user-visible change); migration of the
group/tool cards follows. Widget test + golden included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 10:52:27 +02:00
co-authored by Claude Opus 4.8
parent 8cc238cb75
commit 9ed2475fe1
8 changed files with 613 additions and 0 deletions
@@ -0,0 +1,100 @@
import 'package:alchemist/alchemist.dart';
import 'package:clide/widgets/widgets.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());
// A stand-in inner item card: content + its own per-item status mark (the
// collapser carries the aggregate; items keep their own — T-305).
Widget item(String label, ClideRunStatus status) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF393E48)),
borderRadius: BorderRadius.circular(3),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
children: [
Expanded(child: Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF6A7280)), textDirection: TextDirection.ltr)),
ClideStatusIndicator(status: status, size: 12),
],
),
),
),
);
goldenTest(
'ClideCollapserCard (T-305): collapsed color variants + expanded inner canvas',
fileName: 'collapser_card',
builder: () => GoldenTestGroup(
columns: 1,
children: [
GoldenTestScenario(
name: 'collapsed — default (muted)',
child: _wrap(
f,
const ClideCollapserCard(
label: 'Activity',
collapsedSummary: 'Read conversation_view.dart',
counter: '3 steps',
status: ClideRunStatus.success,
children: [SizedBox.shrink()])),
),
GoldenTestScenario(
name: 'collapsed — edits (teal color)',
child: _wrap(
f,
const ClideCollapserCard(
label: 'Edits',
color: Color(0xFF00AB9A),
collapsedSummary: 'clide_markdown.dart',
counter: '7 edits',
status: ClideRunStatus.success,
children: [SizedBox.shrink()])),
),
GoldenTestScenario(
name: 'collapsed — error (red color)',
child: _wrap(
f,
const ClideCollapserCard(
label: 'Bash',
color: Color(0xFFF06C6F),
collapsedSummary: 'npm test',
counter: '1 step',
status: ClideRunStatus.error,
children: [SizedBox.shrink()])),
),
GoldenTestScenario(
name: 'expanded — inner canvas of item cards',
child: _wrap(
f,
ClideCollapserCard(
label: 'Edits',
color: const Color(0xFF00AB9A),
counter: '3 edits',
status: ClideRunStatus.success,
initiallyExpanded: true,
children: [
item('clide_markdown.dart · _urlLinkSpan', ClideRunStatus.success),
item('clide_markdown.dart · _isHttpUrl', ClideRunStatus.success),
item('clide_markdown.dart · build()', ClideRunStatus.success),
],
),
),
),
],
),
);
}
Widget _wrap(KernelFixture f, Widget child) => SizedBox(width: 380, child: harness(f, child));
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -0,0 +1,139 @@
/// Widget tests for [ClideCollapserCard] (T-305) — the conversation stream's
/// category-3 collapsible card. Covers the collapsed ticker chrome (label +
/// echoed summary + fixed counter + aggregate status), expand/collapse via the
/// row, the background-as-toggle, item taps not hijacked, the keyboard/AT path,
/// and the `color` driving border + label tint.
library;
import 'package:clide/widgets/widgets.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(() => f.dispose());
Future<void> pump(
WidgetTester tester, {
List<Widget>? children,
String label = 'Edits',
String? summary = 'clide_markdown.dart',
String? counter = '3 edits',
ClideRunStatus? status = ClideRunStatus.success,
Color? color,
bool expanded = false,
}) async {
await tester.pumpWidget(harness(
f,
Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: ClideCollapserCard(
label: label,
collapsedSummary: summary,
counter: counter,
status: status,
color: color,
initiallyExpanded: expanded,
children: children ?? const [Text('item body', textDirection: TextDirection.ltr)],
),
),
),
));
await tester.pump();
}
testWidgets('collapsed ticker shows label, echoed summary, counter + status; items hidden', (tester) async {
await pump(tester);
expect(find.text('Edits'), findsOneWidget);
expect(find.text('clide_markdown.dart'), findsOneWidget);
expect(find.text('3 edits'), findsOneWidget);
expect(find.byType(ClideStatusIndicator), findsOneWidget);
expect(find.bySemanticsLabel('Edits, 3 edits, collapsed'), findsOneWidget);
expect(find.text('item body'), findsNothing);
});
testWidgets('tapping the ticker expands to the framed item canvas', (tester) async {
await pump(tester);
await tester.tap(find.bySemanticsLabel('Edits, 3 edits, collapsed'));
await tester.pump();
expect(find.bySemanticsLabel('Edits, 3 edits, expanded'), findsOneWidget);
expect(find.text('item body'), findsOneWidget);
// The label + chrome persist in the expanded header.
expect(find.text('Edits'), findsOneWidget);
expect(find.text('3 edits'), findsOneWidget);
});
testWidgets('a single item still sits in the list (1-item collapser)', (tester) async {
await pump(tester, counter: '1 edit', children: const [Text('only item', textDirection: TextDirection.ltr)], expanded: true);
expect(find.text('only item'), findsOneWidget);
expect(find.text('1 edit'), findsOneWidget);
});
testWidgets('a tap on the frame BACKGROUND (gutter) collapses it', (tester) async {
await pump(tester, expanded: true);
expect(find.text('item body'), findsOneWidget);
// The left gutter (x = left+4) is collapser background — items start at
// left+10 — and below the header, so this hits the background toggle.
final r = tester.getRect(find.byType(ClideCollapserCard));
await tester.tapAt(Offset(r.left + 4, r.center.dy));
await tester.pump();
expect(find.text('item body'), findsNothing);
expect(find.bySemanticsLabel('Edits, 3 edits, collapsed'), findsOneWidget);
});
testWidgets('a tap on an item does NOT toggle the collapser', (tester) async {
await pump(tester, expanded: true);
await tester.tap(find.text('item body'));
await tester.pump();
expect(find.text('item body'), findsOneWidget); // still expanded
expect(find.bySemanticsLabel('Edits, 3 edits, expanded'), findsOneWidget);
});
testWidgets('the ticker is keyboard-focusable and toggles on Activate (a11y)', (tester) async {
await pump(tester);
final focusWidget = tester.widget<Focus>(
find.ancestor(of: find.text('clide_markdown.dart'), 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('Edits, 3 edits, expanded'), findsOneWidget);
});
testWidgets('color drives the border + the label tint', (tester) async {
const teal = Color(0xFF00AB9A);
await pump(tester, color: teal);
// Label text carries the color.
final label = tester.widget<ClideText>(find.byWidgetPredicate((w) => w is ClideText && w.data == 'Edits'));
expect(label.color, teal);
// Some frame Container carries a border in the color.
expect(
find.byWidgetPredicate((w) {
if (w is! Container) return false;
final d = w.decoration;
return d is BoxDecoration && d.border?.bottom.color == teal || (d is BoxDecoration && d.border is Border && (d.border as Border).top.color == teal);
}),
findsWidgets,
);
});
testWidgets('no counter + no status renders a bare ticker without error', (tester) async {
await pump(tester, counter: null, status: null, summary: null);
expect(find.text('Edits'), findsOneWidget);
expect(find.byType(ClideStatusIndicator), findsNothing);
expect(tester.takeException(), isNull);
expect(find.bySemanticsLabel('Edits, collapsed'), findsOneWidget);
});
}