diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 232abba5..cfa2e017 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 1459d3a8..27be0824 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/lib/widgets/src/clide_card_metrics.dart b/lib/widgets/src/clide_card_metrics.dart new file mode 100644 index 00000000..b24413d3 --- /dev/null +++ b/lib/widgets/src/clide_card_metrics.dart @@ -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; diff --git a/lib/widgets/src/clide_collapser_card.dart b/lib/widgets/src/clide_collapser_card.dart new file mode 100644 index 00000000..47eeede1 --- /dev/null +++ b/lib/widgets/src/clide_collapser_card.dart @@ -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 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 createState() => _ClideCollapserCardState(); +} + +class _ClideCollapserCardState extends State { + 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), + ), + ); +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 55db7cda..60ec9e26 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/test/goldens/collapser_card_goldens_test.dart b/test/goldens/collapser_card_goldens_test.dart new file mode 100644 index 00000000..b6322fb8 --- /dev/null +++ b/test/goldens/collapser_card_goldens_test.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)); diff --git a/test/goldens/goldens/linux/collapser_card.png b/test/goldens/goldens/linux/collapser_card.png new file mode 100644 index 00000000..2b00cf5a Binary files /dev/null and b/test/goldens/goldens/linux/collapser_card.png differ diff --git a/test/widgets/src/clide_collapser_card_test.dart b/test/widgets/src/clide_collapser_card_test.dart new file mode 100644 index 00000000..48a3df19 --- /dev/null +++ b/test/widgets/src/clide_collapser_card_test.dart @@ -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 pump( + WidgetTester tester, { + List? 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( + 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(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); + }); +}