From 9ed2475fe15b68fc442d0f3f92f218478c26bf6b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 10 Jun 2026 10:52:27 +0200 Subject: [PATCH] add ClideCollapserCard primitive + shared card metrics (T-305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 88 +++++++ .pql/changelog/tickets/2026-06.sql | 30 +++ lib/widgets/src/clide_card_metrics.dart | 24 ++ lib/widgets/src/clide_collapser_card.dart | 230 ++++++++++++++++++ lib/widgets/widgets.dart | 2 + test/goldens/collapser_card_goldens_test.dart | 100 ++++++++ test/goldens/goldens/linux/collapser_card.png | Bin 0 -> 36137 bytes .../src/clide_collapser_card_test.dart | 139 +++++++++++ 8 files changed, 613 insertions(+) create mode 100644 lib/widgets/src/clide_card_metrics.dart create mode 100644 lib/widgets/src/clide_collapser_card.dart create mode 100644 test/goldens/collapser_card_goldens_test.dart create mode 100644 test/goldens/goldens/linux/collapser_card.png create mode 100644 test/widgets/src/clide_collapser_card_test.dart 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 0000000000000000000000000000000000000000..2b00cf5ad33523f480f34591c645f833ba59e883 GIT binary patch literal 36137 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 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); + }); +}