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 docs/design/wireframes/cards/collapser-card.json (+ .png) — collapsed (3 color
variants), expanded with nested sub-cards, annotated for the counter slot + 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; 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. 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); 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_accordion.dart';
export 'src/clide_anchored.dart'; export 'src/clide_anchored.dart';
export 'src/clide_button.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_column_hat.dart';
export 'src/clide_code_block.dart'; export 'src/clide_code_block.dart';
export 'src/clide_divider.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);
});
}