diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 005f9255..32e5fe51 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3953,3 +3953,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:39:04', '2026-06-11 22:39:04', '2026-06-11 22:39:04', NULL, 'ad1c483fe26d5e417b2e1cd8986114f3', 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 ('06FBHBWE2W1226T58CX37E50HC', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:39:32', '2026-06-11 22:39:32', '2026-06-11 22:39:32', NULL, '9983d6ca4c8b8b49724fb5fe42b541cd', 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 ('06FBHBWE2W1226T58CX37E50HC', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:44:05', '2026-06-11 22:44:05', '2026-06-11 22:44:05', NULL, '7846c244fccd1cd444f6715ed7472549', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:44:30', '2026-06-11 22:44:30', '2026-06-11 22:44:30', NULL, 'f20e68df446500477d6a3c7761e354c3', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:46:46', '2026-06-11 22:46:46', '2026-06-11 22:46:46', NULL, '434ffa25b18e16c5b158c61df6f40bf8', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 01f10744..57ba0825 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4025,3 +4025,13 @@ Fix: dispose/shutdown the previous service set before (or while) standing up the 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 ('06FBHBWE2W1226T58CX37E50HC', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Project switch leaks the entire previous workspace service set', 'lib/main.dart:335-344 — switching projects builds a new dispatcher with fresh PaneRegistry, FilesService, EditorRegistry, etc., but nothing calls the old set''s shutdown() methods (which exist and have zero callers). Old file watchers keep emitting into the new workspace''s bus. Fix: dispose/shutdown the previous service set before (or while) standing up the new one. Test: after a workspace switch, the old FilesService watcher no longer delivers events.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:13', '2026-06-11 22:44:05', NULL, 'ab0fad47ab342a3578b9fec64dd886e7', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish. + +Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state. + +Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 22:44:30', NULL, 'fa9b28e5617b452b34d21b58d67ed3ca', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'ClideCollapserCard excludeSemantics wipes expanded children from the a11y tree', 'lib/widgets/src/clide_collapser_card.dart:92-101 — excludeSemantics: true on the card wipes every expanded child from the a11y tree: a screen-reader user can expand a run/tool card and hear nothing inside it. A11y is a Tier-0 contract in this repo, so this is a contract breach, not polish. + +Fix: exclude semantics only while collapsed (or scope the exclusion to the chrome, not the body); keep the header announcing expanded/collapsed state. + +Acceptance: semantics test asserting expanded-card children are present in the semantics tree and absent (or summarized) when collapsed; make test-a11y green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:57:04', '2026-06-11 22:46:46', NULL, '1a642a72ded99e1710d9cd5e4b73a591', 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/CHANGELOG.md b/CHANGELOG.md index 84f8c1bf..0f1c5a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. alive on every project switch, with stale watcher events leaking into the new workspace. (T-367) +- **Expanded activity cards are readable by screen readers again.** The + collapser's summarized button semantics excluded the whole card, so + expanding a run announced nothing inside it; the exclusion is now scoped + to the header and the inner cards stay in the a11y tree. (T-370) + - **Accepting ExitPlanMode now leaves plan mode in the conversation panel.** Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying session out of plan mode, but clide's tracked permission mode didn't follow, diff --git a/lib/widgets/src/clide_collapser_card.dart b/lib/widgets/src/clide_collapser_card.dart index 66dfb8d3..2c012c8a 100644 --- a/lib/widgets/src/clide_collapser_card.dart +++ b/lib/widgets/src/clide_collapser_card.dart @@ -88,16 +88,24 @@ class _ClideCollapserCardState extends State { @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; + return Padding( + padding: const EdgeInsets.only(bottom: kClideCardGap), + child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens), + ); + } + + /// Summarized button semantics for the toggle. Scoped to the HEADER only — + /// wrapping the whole card excluded every expanded child from the a11y + /// tree, so a screen-reader user could expand a run and hear nothing + /// inside it (T-370). Collapsed, the header summary IS the whole card. + Widget _headerSemantics({required Widget child}) { 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), - ), + child: child, ); } @@ -146,18 +154,20 @@ class _ClideCollapserCardState extends State { } /// 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), + Widget _tickerRow(SurfaceTokens tokens) => _headerSemantics( + child: 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), ), - child: _headerContent(tokens, expanded: false), ), ); @@ -172,17 +182,19 @@ class _ClideCollapserCardState extends State { 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. + // item taps are never intercepted. Excluded from focus traversal AND + // semantics — the header caret is the single keyboard/AT stop. Positioned.fill( child: ExcludeFocus( - child: ClideTappable(onTap: _toggle, tooltip: 'Collapse', builder: (_, _, _) => const SizedBox.expand()), + child: ExcludeSemantics( + child: ClideTappable(onTap: _toggle, tooltip: 'Collapse', builder: (_, _, _) => const SizedBox.expand()), + ), ), ), Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _headerRow(tokens), + _headerSemantics(child: _headerRow(tokens)), // Even padding around the inner item canvas (T-305): the sides + // top match, and each inner item carries a matching bottom margin // (so the last item's margin is the bottom inset and items in a diff --git a/test/widgets/src/clide_collapser_card_test.dart b/test/widgets/src/clide_collapser_card_test.dart index 869affa3..d43964b2 100644 --- a/test/widgets/src/clide_collapser_card_test.dart +++ b/test/widgets/src/clide_collapser_card_test.dart @@ -155,6 +155,25 @@ void main() { ); }); + // T-370: the summarized button semantics used to wrap the WHOLE card with + // excludeSemantics, wiping every expanded child from the a11y tree — a + // screen-reader user could expand a run and hear nothing inside it. + testWidgets('expanded children are present in the semantics tree (T-370)', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester, expanded: true); + expect(find.bySemanticsLabel('Edits, 3 edits, expanded'), findsOneWidget); + expect(find.bySemanticsLabel('item body'), findsOneWidget, reason: 'expanded content must be readable by AT'); + handle.dispose(); + }); + + testWidgets('collapsed children are absent from the semantics tree, header summarizes (T-370)', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester); + expect(find.bySemanticsLabel('Edits, 3 edits, collapsed'), findsOneWidget); + expect(find.bySemanticsLabel('item body'), findsNothing); + handle.dispose(); + }); + 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);