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
+88
View File
@@ -2348,3 +2348,91 @@ Nested sub-cards render the SAME primitive (card-within-card consistency).
docs/design/wireframes/cards/collapser-card.json (+ .png) collapsed (3 color
variants), expanded with nested sub-cards, annotated for the counter slot +
edge-anchored icons.', NULL, '2026-06-10 08:16:05', '2026-06-10 08:16:05', '2026-06-10 08:16:05', NULL, 'fe6c300aa083f7faa80d3d78bd1ba0bf', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16FJ7KXGFHQXEG88MYRFTG', 'description', 'Group/tool collapser cards (ClideHolderCard vs ConversationCard) handle collapse + visuals inconsistently; extract one shared collapser primitive with a color property, fixed-width counter slot, and the status icon hard against the card edge.
## Bug / discrepancy
There are two collapser-card families with inconsistent collapse handling and
"card within card" chrome:
- **`ClideHolderCard`** (lib/builtin/claude/src/holder_card.dart, T-266) the
group container for Activity (`_ActivityCard`) and Edits (`_EditRunCard`).
Collapse = ticker row + frame background + a header caret. Header order:
`[chevron] summary [status] [count]`. Status indicator sits INBOARD, left
of the count label. Border/text hardcoded to `panelBorder` / `globalTextMuted`
(no color knob).
- **`ConversationCard`** (lib/builtin/claude/src/conversation_card.dart, T-262)
the per-tool merged card. Collapse = a single leading caret only (no
background/ticker). Header order: `[caret] label summary/spacer [status]
[actions]`. Has `borderColor` + `accent`.
So the toggle is attached differently, the status mark sits in a different
place, the counter has no fixed slot, and color customization exists on one but
not the other. They should grab the SAME widget (each its own instance NOT
collapsing across types).
## Proposal one `ClideCollapserCard` primitive
Both families render through a single collapser primitive. Requirements:
1. **`color` property** drives the border AND the label/text color, so each
instance keeps its visual identity (activity = muted, edits = accent,
error = red, sub-agent, ) through one widget. Default = panelBorder/muted.
2. **Fixed-width counter slot** the `N steps` / `N edits` count lives in a
right-aligned fixed-width slot so it (and the trailing status icon) never
shift as the number grows or the status appears/changes.
3. **Status icon hard against the card edge** flip the current
`[count][status]` to `[status]` against the right edge with the counter
inboard of it. The chevron already hugs the LEFT edge. Rule: both icons hug
the card edges; text sits inboard.
4. **Consistent collapse/expand attachment** across both uses (keep the
group-container background-tap-to-collapse for the tail-follow case, D-78,
but the visible control + header layout are identical).
5. Status glyphs: `ClideRunStatus` running (logo-mark spinner ) / success /
error , reusing ClideStatusIndicator.
Nested sub-cards render the SAME primitive (card-within-card consistency).
## Scope
- New `ClideCollapserCard` in lib/widgets/src/ (exported from widgets.dart).
- Migrate `ClideHolderCard` (Activity/Edits) and `ConversationCard` (merged
tool card) onto it; keep per-type content/labels.
- Hold visual fidelity via goldens (conversation_card_goldens_test,
ClideHolderCard golden) + a11y (expanded/collapsed semantics, focusable
toggle).
## Wireframe
docs/design/wireframes/cards/collapser-card.json (+ .png) collapsed (3 color
variants), expanded with nested sub-cards, annotated for the counter slot +
edge-anchored icons.', 'Unify the conversation-panel collapsible cards onto one ClideCollapserCard primitive, and share card spacing across all card categories. Supersedes ClideHolderCard (T-266) and the collapse logic in ConversationCard (T-262) for the tool path.
## Conversation-panel card model (agreed 2026-06-10)
### Shared spacing constants, NOT a shared wrapper
All card categories share a small set of spacing CONSTANTS (inter-card bottom gap, inner padding, corner radius). NOT a forced common wrapper widget each category is its own widget; they just pull the same spacing tokens so the stream reads as one consistent rhythm.
### Three card categories
1. **Dialog cards** carry the side stripe marking who is speaking (user / Claude / agent). Prose / attribution. Not collapsible. (Today: ConversationCard stripe variant.)
2. **Simple cards** a single item shown fully open in the stream, never collapses (e.g. the image-show card; more to come). Standalone display: no chevron, no status chrome.
3. **Collapsibles** the unified collapser. Covers edits, bash, task updates, runs every tool use. Behaves like the bash card should:
- The whole card is clickable to collapse/expand.
- Collapsed: title = the echoed last content line (like bash now) + an item count + aggregate status (spinner / check / cross).
- Expanded: an inner canvas holding the nested item card(s), each item in its own inner card.
- A single item still gets its own inner card inside the collapser when open, and pushes its status / count / last-line up to the collapser header.
- Inner item cards ALSO show their own per-item status (check / cross / spinner) when expanded; the collapser header carries the aggregate.
- Chrome (per the wireframe): `color` (outer border + chevron / label / text), fixed-width counter slot, status icon hard against the right edge, chevron hard against the left edge.
## Scope
- New `ClideCollapserCard` in lib/widgets/ (exported from widgets.dart) the category-3 primitive: a list of 1..N inner item cards, collapsed ticker <-> expanded inner canvas, color / fixed-counter / edge-status / edge-chevron chrome, background + caret toggle (D-78 tail-follow), aggregate status + count + echoed-title computed from the items.
- Shared card-spacing constants consumed by all three categories.
- ALL tool uses render as collapsers single ones as a 1-item list (Bash, Read, Edit, Task, edits runs, activity runs, etc.).
- Inner item cards: content + their own per-item status; no own collapse; no stripe.
- Dialog cards (1) and simple cards (2) are NOT pulled into the collapser they only adopt the shared spacing constants (keep stripe / inner config).
## Verify
- Goldens regenerated (holder_card, conversation_card_merged) + a11y (expanded / collapsed semantics, focusable toggle).
- Wireframe: docs/design/wireframes/cards/collapser-card.{json,png}.
- After landing: update the ui-design skill''s conversation-panel guidance to describe the three card categories + the collapser.
', NULL, '2026-06-10 08:43:03', '2026-06-10 08:43:03', '2026-06-10 08:43:03', NULL, '15f42c1d62be96cf45d583bf3ac704cd', 2) ON CONFLICT(hash) DO NOTHING;
+30
View File
@@ -1139,3 +1139,33 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
Adjust the skill so that after step 5 (showing an exported screen inline), it STOPS and waits for explicit user approval/feedback before authoring or exporting the next screen. One screen at a time: show it, ask, then proceed only on a go-ahead. Do not fan out a whole set of wireframes unprompted.
Acceptance: SKILL.md instructs the agent to surface one screen and pause for user approval before continuing; the loop is approval-gated, framing wireframing as a back-and-forth design conversation rather than a one-shot generation run.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:17:57', '2026-06-10 08:17:57', NULL, 'e9e59e2c52e643e2c0a2831c90d8cda8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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 (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB16FJ7KXGFHQXEG88MYRFTG', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Unify collapser cards onto one ClideCollapserCard primitive', 'Unify the conversation-panel collapsible cards onto one ClideCollapserCard primitive, and share card spacing across all card categories. Supersedes ClideHolderCard (T-266) and the collapse logic in ConversationCard (T-262) for the tool path.
## Conversation-panel card model (agreed 2026-06-10)
### Shared spacing constants, NOT a shared wrapper
All card categories share a small set of spacing CONSTANTS (inter-card bottom gap, inner padding, corner radius). NOT a forced common wrapper widget each category is its own widget; they just pull the same spacing tokens so the stream reads as one consistent rhythm.
### Three card categories
1. **Dialog cards** carry the side stripe marking who is speaking (user / Claude / agent). Prose / attribution. Not collapsible. (Today: ConversationCard stripe variant.)
2. **Simple cards** a single item shown fully open in the stream, never collapses (e.g. the image-show card; more to come). Standalone display: no chevron, no status chrome.
3. **Collapsibles** the unified collapser. Covers edits, bash, task updates, runs every tool use. Behaves like the bash card should:
- The whole card is clickable to collapse/expand.
- Collapsed: title = the echoed last content line (like bash now) + an item count + aggregate status (spinner / check / cross).
- Expanded: an inner canvas holding the nested item card(s), each item in its own inner card.
- A single item still gets its own inner card inside the collapser when open, and pushes its status / count / last-line up to the collapser header.
- Inner item cards ALSO show their own per-item status (check / cross / spinner) when expanded; the collapser header carries the aggregate.
- Chrome (per the wireframe): `color` (outer border + chevron / label / text), fixed-width counter slot, status icon hard against the right edge, chevron hard against the left edge.
## Scope
- New `ClideCollapserCard` in lib/widgets/ (exported from widgets.dart) the category-3 primitive: a list of 1..N inner item cards, collapsed ticker <-> expanded inner canvas, color / fixed-counter / edge-status / edge-chevron chrome, background + caret toggle (D-78 tail-follow), aggregate status + count + echoed-title computed from the items.
- Shared card-spacing constants consumed by all three categories.
- ALL tool uses render as collapsers single ones as a 1-item list (Bash, Read, Edit, Task, edits runs, activity runs, etc.).
- Inner item cards: content + their own per-item status; no own collapse; no stripe.
- Dialog cards (1) and simple cards (2) are NOT pulled into the collapser they only adopt the shared spacing constants (keep stripe / inner config).
## Verify
- Goldens regenerated (holder_card, conversation_card_merged) + a11y (expanded / collapsed semantics, focusable toggle).
- Wireframe: docs/design/wireframes/cards/collapser-card.{json,png}.
- After landing: update the ui-design skill''s conversation-panel guidance to describe the three card categories + the collapser.
', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 08:15:39', '2026-06-10 08:43:03', NULL, '444e8c535d796bae0519d4a4761b12e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_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);
+24
View File
@@ -0,0 +1,24 @@
/// Shared spacing for conversation-stream cards (T-305).
///
/// The stream has three card categories — dialog cards (speaker stripe), simple
/// cards (a single always-open item), and collapsibles ([ClideCollapserCard]).
/// They are deliberately NOT forced through one wrapper widget; instead they all
/// pull these constants so the stream reads as one consistent rhythm. Change a
/// value here and every category moves together.
library;
/// Vertical gap below each stream card (its bottom margin). Matches the rhythm
/// the prose cards established (T-282).
const double kClideCardGap = 14;
/// Corner radius for stream card frames.
const double kClideCardRadius = 4;
/// Horizontal / vertical padding inside a card header row.
const double kClideCardHeaderPadH = 10;
const double kClideCardHeaderPadV = 6;
/// Fixed width of the right-aligned counter slot in a collapser header, so the
/// count (and the status icon hard against the edge) never shift as the number
/// grows or the status appears (T-305).
const double kClideCardCounterSlotWidth = 60;
+230
View File
@@ -0,0 +1,230 @@
/// The conversation stream's collapsible card (T-305) — the category-3 card.
///
/// Holds a list of `1..N` inner item cards as one unit. A single item is just a
/// list of one: there is no separate single-card path, which keeps the model
/// uniform and reliable.
///
/// - **Collapsed** (default): a one-line ticker — the [label], the echoed
/// [collapsedSummary] (the run's latest content line), a fixed-width [counter]
/// ("3 steps"), and the aggregate [status] (spinner / check / cross). The
/// whole row is the toggle.
/// - **Expanded**: a framed inner canvas wrapping the item cards; clicking the
/// frame BACKGROUND (padding, the gaps between items, the gutter — anywhere an
/// item doesn't cover) collapses it. An explicit focusable header caret keeps
/// the control keyboard/AT reachable while a run streams (the tail-follow race
/// — a top-only control would be unreachable as the view auto-scrolls, D-78).
///
/// Chrome is consistent across every collapser: the chevron is hard against the
/// LEFT edge, the status icon hard against the RIGHT edge, the counter sits in a
/// fixed-width slot just inboard of it, and [color] drives the border + the
/// chevron/label tint so each instance keeps its visual identity through one
/// widget. The inner item cards are content (they keep their OWN per-item status
/// + stripe); the aggregate status/count/title shown here are computed by the
/// caller and passed in — this widget stays free of conversation semantics.
library;
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/src/clide_card_metrics.dart';
import 'package:clide/widgets/src/clide_icon.dart';
import 'package:clide/widgets/src/clide_status_indicator.dart';
import 'package:clide/widgets/src/clide_tappable.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/icons/chevron.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
class ClideCollapserCard extends StatefulWidget {
const ClideCollapserCard({
super.key,
required this.label,
required this.children,
this.color,
this.collapsedSummary,
this.counter,
this.status,
this.initiallyExpanded = false,
});
/// Header label, shown collapsed AND expanded (e.g. `Edits`, `Bash`).
final String label;
/// The inner item cards, wrapped in the frame when expanded. A single item is
/// a list of one.
final List<Widget> children;
/// Border + chevron/label tint. Null → panel border with a muted label.
final Color? color;
/// The run's latest content line, echoed beside the label while collapsed.
final String? collapsedSummary;
/// Fixed-width right-aligned count, e.g. `3 steps`. Null → no counter slot.
final String? counter;
/// Aggregate run status (spinner / check / cross) at the right edge. The inner
/// cards still carry their own per-item marks; this is the roll-up.
final ClideRunStatus? status;
final bool initiallyExpanded;
@override
State<ClideCollapserCard> createState() => _ClideCollapserCardState();
}
class _ClideCollapserCardState extends State<ClideCollapserCard> {
late bool _expanded = widget.initiallyExpanded;
final FocusNode _controlFocus = FocusNode(debugLabel: 'collapser-control');
@override
void dispose() {
_controlFocus.dispose();
super.dispose();
}
void _toggle() => setState(() => _expanded = !_expanded);
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final semanticCount = widget.counter == null ? '' : ', ${widget.counter}';
return Semantics(
button: true,
expanded: _expanded,
label: '${widget.label}$semanticCount, ${_expanded ? 'expanded' : 'collapsed'}',
excludeSemantics: true,
child: Padding(
padding: const EdgeInsets.only(bottom: kClideCardGap),
child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens),
),
);
}
/// The header row content, shared by the collapsed ticker and the expanded
/// header so the chrome is pixel-identical between states.
Widget _headerContent(SurfaceTokens tokens, {required bool expanded}) {
final accent = widget.color ?? tokens.globalTextMuted;
final showSummary = !expanded && widget.collapsedSummary != null;
return Row(
children: [
// Chevron — hard against the left edge; the toggle.
ClideIcon(expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: accent),
const SizedBox(width: 8),
ClideText(widget.label, fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: accent),
if (showSummary) ...[
const SizedBox(width: 10),
Expanded(
child: ClideText(
widget.collapsedSummary!,
fontSize: clideFontCaption,
fontFamily: clideMonoFamily,
color: tokens.globalTextMuted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
] else
const Spacer(),
// Counter — fixed-width right-aligned slot so the status icon never shifts.
if (widget.counter != null) ...[
const SizedBox(width: 8),
SizedBox(
width: kClideCardCounterSlotWidth,
child: Align(
alignment: Alignment.centerRight,
child: ClideText(widget.counter!, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1),
),
),
],
// Status — the icon hard against the right edge.
if (widget.status != null) ...[
const SizedBox(width: 8),
ClideStatusIndicator(status: widget.status!, size: 12),
],
],
);
}
/// Collapsed: the ticker row IS the toggle, focusable for keyboard/AT.
Widget _tickerRow(SurfaceTokens tokens) => ClideTappable(
focusNode: _controlFocus,
onTap: _toggle,
tooltip: 'Expand',
builder: (context, hovered, focused) => Container(
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
decoration: BoxDecoration(
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
border: Border.all(color: widget.color ?? tokens.panelBorder),
borderRadius: BorderRadius.circular(kClideCardRadius),
),
child: _headerContent(tokens, expanded: false),
),
);
/// Expanded: a framed inner canvas wrapping the item cards. The frame
/// BACKGROUND is a gesture target behind the items that only fires for hits
/// the items don't consume.
Widget _expandedFrame(SurfaceTokens tokens) => DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: widget.color ?? tokens.panelBorder),
borderRadius: BorderRadius.circular(kClideCardRadius),
),
child: Stack(
children: [
// Background toggle: behind the items, not a whole-card overlay, so
// item taps are never intercepted. Excluded from focus traversal —
// the header caret is the single keyboard stop.
Positioned.fill(
child: ExcludeFocus(
child: ClideTappable(
onTap: _toggle,
tooltip: 'Collapse',
builder: (_, __, ___) => const SizedBox.expand(),
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_headerRow(tokens),
Padding(
padding: const EdgeInsets.fromLTRB(kClideCardHeaderPadH, 0, kClideCardHeaderPadH, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final child in widget.children)
// Each item opaquely consumes its full bounds so a body
// tap interacts with that card (or does nothing), never
// the collapser background. Deeper controls still win;
// only taps are absorbed, so selection drags pass through.
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: child,
),
],
),
),
],
),
],
),
);
/// The explicit, focusable collapse control in the expanded header. A
/// background tap alone is not keyboard/AT reachable, so this keeps the
/// control on the Tab path and Enter/Space-activatable (D-78).
Widget _headerRow(SurfaceTokens tokens) => ClideTappable(
focusNode: _controlFocus,
onTap: _toggle,
tooltip: 'Collapse',
builder: (context, hovered, focused) => Container(
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
decoration: BoxDecoration(
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
border: Border(bottom: BorderSide(color: widget.color ?? tokens.panelBorder)),
),
child: _headerContent(tokens, expanded: true),
),
);
}
+2
View File
@@ -8,6 +8,8 @@ library;
export 'src/clide_accordion.dart';
export 'src/clide_anchored.dart';
export 'src/clide_button.dart';
export 'src/clide_card_metrics.dart';
export 'src/clide_collapser_card.dart';
export 'src/clide_column_hat.dart';
export 'src/clide_code_block.dart';
export 'src/clide_divider.dart';
@@ -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);
});
}