scope collapser-card semantics exclusion to the header (T-370)
The summarized button semantics (label, expanded/collapsed state) wrapped the entire card with excludeSemantics, so every expanded child vanished from the a11y tree — a screen-reader user could expand a run and hear nothing inside it. The exclusion now wraps only the header (ticker when collapsed, header row when expanded); inner item cards stay readable, and the redundant background-toggle tappable is explicitly excluded so the header stays the single AT stop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -88,16 +88,24 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
||||
@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<ClideCollapserCard> {
|
||||
}
|
||||
|
||||
/// 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<ClideCollapserCard> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user