diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 32d902f0..7bb03cf1 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -176,3 +176,4 @@ Acceptance: Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', NULL, '2026-06-03 15:17:10', '2026-06-03 15:17:10', '2026-06-03 15:17:10', NULL, 'be73ea1e861930fc12fd9d6050cc6841', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-230', 'status', 'in_progress', 'done', NULL, '2026-06-03 15:17:33', '2026-06-03 15:17:33', '2026-06-03 15:17:33', NULL, '069a613ac7439a5d19da505712da8341', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-237', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 15:43:55', '2026-06-03 15:43:55', '2026-06-03 15:43:55', NULL, '8505c7a515c60be219fdaafd5c0c4acc', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 247dffd5..0e8ec249 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -446,3 +446,23 @@ Acceptance: 6. Keyboard + screen-reader accessible (expand/collapse, step count announced). Implemented (2026-06-03): pure grouping in activity_cluster.dart (groupConversation + FoldLevel none/tools/thinking/everything + RenderGroup StickyItem/FoldedCluster), fully unit-tested. Activity card + grouping wired into conversation_view.dart (collapsed live ticker of the latest step + step count; click/Enter expands to the folded steps in order; Semantics announces count + expanded/collapsed). Default level = L1 (tools): tool calls+results fold; user/Claude prose, FAILED results (sticky, surfaced), diffs (Edit/Write), and thinking stay first-class. Added FoldLevel.none (no folding) used by the existing item-level renderer tests. DEFERRED (acceptance 4''s persistence): the fold level is a switchable ConversationView parameter (proven by tests; none/L1/L2/L3 re-group) but is NOT yet wired to a persisted user setting + a UI control — the live pane uses the L1 default. Follow-up: read it from settings (ctx.settings) + a control to change it. Did NOT match the wireframe pixel-for-pixel; functional shape per the refinement decisions.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 11:15:39', '2026-06-03 15:17:33', NULL, '2f9702465f4d0079bca7f4f4e7b60095', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-236', 'story', 'T-132', 'Render pasted-image @path tokens as thumbnails in the message log', 'When a user message contains a pasted-screenshot reference — an @ token pointing at an image (the composer''s ComposerAttachment.pathToken format, e.g. @/home//.cache/clide/pasted/paste-.png, see screenshot) — the conversation log renders it as the raw path string. Show it as an inline THUMBNAIL instead. + +The composer already renders pre-send attachments as Image.file thumbnail chips (clipboard_paste.dart + claude_composer _chip); this carries that into the post-send message log (UserMessage rendering in conversation_view/conversation_card). + +Scope: +- When rendering UserMessage text, detect @ tokens; for image paths (reuse the extension check behind _looksLikeImage: .png/.jpg/.jpeg/.gif/.webp/.bmp), render a bounded Image.file thumbnail in place of the bare token. Surrounding prose still renders normally; a message can carry multiple tokens. +- Click/activate the thumbnail to open the full-size image (a preview dialog). Keyboard-operable + a11y label (filename). +- Graceful fallback: a missing/unreadable file (pasted temp files can be cleaned up) degrades to a small placeholder or the path text via Image.file errorBuilder — never a crash/exception. +- Non-image @path tokens MAY render as a file chip (filename + icon) like the composer, but image thumbnails are the focus. +- Note: Image.file reads the path directly via dart:io, so this is NOT gated by the files.read allow-list (D-80) — the cache dir is outside the workspace and that''s fine for display. +- Display-only: the text actually sent to Claude is unchanged; this only affects rendering. + +Acceptance: +1. A user message with an @ image token shows an inline thumbnail in the log (not the raw path). +2. Clicking/activating the thumbnail opens the full image. +3. A missing/unreadable referenced file degrades to a placeholder (or the path text), no exception. +4. Prose and any non-image @path tokens around it still render readably; multiple tokens in one message all resolve. +5. The message content delivered to Claude is unchanged (render-only). Relates to T-142 (paste attachments).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-03 15:31:01', '2026-06-03 15:31:01', NULL, '6cf569924ec0dbc31a4b2711aa420be2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-237', 'task', NULL, 'Theme picker polish: collapse -hc into a checkbox, sort, widen, palette icon', 'User feedback on the T-234 status-bar theme popover (screenshot 2026-06-03): (1) the menu order looks random — the -hc variants are interleaved. (2) Hide all the high-contrast theme entries and replace them with a single ''[ ] High contrast'' checkbox at the top; toggling it applies the -hc sibling of the chosen base theme. (3) Widen the popover so long names (''Catppuccin Mocha'') don''t wrap; rows ellipsize. (4) The status-bar items (''application ok'' + theme switcher) float in the middle (boxed into the workspace column between the per-column bottom rails) instead of hugging the window''s right edge — align them right. (5) Replace the swatch dot on the trigger with a Phosphor icon (palette, 0xe6c8, already in the registry — paint-roller has no registered codepoint). Apply the -hc-as-checkbox + sort to the modal picker_view too for consistency. Helpers: base = name without -hc/-cb; show base themes only; resolve(base, hc) -> base+''-hc'' if it exists.', 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-03 15:43:45', '2026-06-03 15:43:45', NULL, '411a82da35f5bad2611cf4f4ff38aa13', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-237', 'task', NULL, 'Theme picker polish: collapse -hc into a checkbox, sort, widen, palette icon', 'User feedback on the T-234 status-bar theme popover (screenshot 2026-06-03): (1) the menu order looks random — the -hc variants are interleaved. (2) Hide all the high-contrast theme entries and replace them with a single ''[ ] High contrast'' checkbox at the top; toggling it applies the -hc sibling of the chosen base theme. (3) Widen the popover so long names (''Catppuccin Mocha'') don''t wrap; rows ellipsize. (4) The status-bar items (''application ok'' + theme switcher) float in the middle (boxed into the workspace column between the per-column bottom rails) instead of hugging the window''s right edge — align them right. (5) Replace the swatch dot on the trigger with a Phosphor icon (palette, 0xe6c8, already in the registry — paint-roller has no registered codepoint). Apply the -hc-as-checkbox + sort to the modal picker_view too for consistency. Helpers: base = name without -hc/-cb; show base themes only; resolve(base, hc) -> base+''-hc'' if it exists.', 'in_progress', 'medium', NULL, NULL, 'D-6', '2026-06-03 15:43:45', '2026-06-03 15:43:55', NULL, 'fc2bc3d466dbae36975e79cabcf289ee', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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/builtin/theme_picker/src/theme_families.dart b/lib/builtin/theme_picker/src/theme_families.dart new file mode 100644 index 00000000..3e533485 --- /dev/null +++ b/lib/builtin/theme_picker/src/theme_families.dart @@ -0,0 +1,29 @@ +/// Theme-family helpers for the picker surfaces (T-237). +/// +/// Bundled themes ship as a base + a high-contrast (`-hc`) / colour-blind +/// (`-cb`) sibling (D-69). The pickers don't list the siblings as separate +/// rows — they show base themes only and expose the variant as a "High +/// contrast" toggle. These pure helpers do the base/sibling bookkeeping so +/// the popover and the modal share one source of truth. +library; + +import 'package:clide/kernel/kernel.dart' show ThemeDefinition; + +bool isHcName(String name) => name.endsWith('-hc'); +bool isCbName(String name) => name.endsWith('-cb'); + +/// The base theme name for any theme (strips a `-hc`/`-cb` suffix). +String baseThemeName(String name) => (isHcName(name) || isCbName(name)) ? name.substring(0, name.length - 3) : name; + +/// Base themes only (no `-hc`/`-cb`), sorted by display name so the list is +/// stable and not "randomly" ordered. +List baseThemes(List available) => + available.where((t) => !isHcName(t.name) && !isCbName(t.name)).toList()..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); + +/// Whether [base] has a high-contrast sibling among [available]. +bool hasHcSibling(List available, String base) => available.any((t) => t.name == '$base-hc'); + +/// The theme name to actually apply for a chosen [base] given the high-contrast +/// toggle: the `-hc` sibling when requested and present, else the base itself. +String resolveThemeName(List available, String base, {required bool highContrast}) => + (highContrast && hasHcSibling(available, base)) ? '$base-hc' : base; diff --git a/lib/builtin/theme_picker/src/theme_status_item.dart b/lib/builtin/theme_picker/src/theme_status_item.dart index a33f81b1..8c80529e 100644 --- a/lib/builtin/theme_picker/src/theme_status_item.dart +++ b/lib/builtin/theme_picker/src/theme_status_item.dart @@ -5,6 +5,7 @@ /// (D-6 parity — both reach the same ThemeController). library; +import 'package:clide/builtin/theme_picker/src/theme_families.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -60,11 +61,7 @@ class _ThemeSwitcherStatusItemState extends State { bottom: screen.height - anchor.dy + 4, child: _ThemePopover( controller: _controller, - onPick: (name) { - _controller.select(name); - _close(); - }, - onDismiss: _close, + onClose: _close, ), ), ], @@ -103,14 +100,11 @@ class _ThemeSwitcherStatusItemState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - // A swatch dot in the theme's accent — the "what theme" cue. - Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: tokens.buttonBackground, shape: BoxShape.circle), - ), + ClideIcon(PhosphorIcons.palette, size: 13, color: tokens.statusBarForeground), const SizedBox(width: 6), - ClideText(label, fontSize: clideFontCaption, color: tokens.statusBarForeground), + // The status bar is all-lowercase; the proper-case name stays + // in the Semantics label for screen readers. + ClideText(label.toLowerCase(), fontSize: clideFontCaption, color: tokens.statusBarForeground), ], ), ), @@ -121,14 +115,15 @@ class _ThemeSwitcherStatusItemState extends State { } } -/// The popover body: a keyboard-navigable list of themes. Autofocuses so -/// arrows/Enter/Esc work immediately; the current theme starts highlighted. +/// The popover body: a "High contrast" toggle then the base themes (the `-hc` +/// siblings collapse into the toggle, T-237). Keyboard-navigable as one list — +/// index 0 is the toggle, 1..N the themes; arrows move, Enter activates, Esc +/// dismisses. Autofocuses on open. class _ThemePopover extends StatefulWidget { - const _ThemePopover({required this.controller, required this.onPick, required this.onDismiss}); + const _ThemePopover({required this.controller, required this.onClose}); final ThemeController controller; - final void Function(String name) onPick; - final VoidCallback onDismiss; + final VoidCallback onClose; @override State<_ThemePopover> createState() => _ThemePopoverState(); @@ -136,14 +131,18 @@ class _ThemePopover extends StatefulWidget { class _ThemePopoverState extends State<_ThemePopover> { final _focus = FocusNode(debugLabel: 'ThemeSwitcher.popover'); - late int _index; + late bool _hc; + int _index = 0; + + List get _themes => baseThemes(widget.controller.available); @override void initState() { super.initState(); - final themes = widget.controller.available; - _index = themes.indexWhere((t) => t.name == widget.controller.currentName); - if (_index < 0) _index = 0; + _hc = isHcName(widget.controller.currentName); + final currentBase = baseThemeName(widget.controller.currentName); + final at = _themes.indexWhere((t) => t.name == currentBase); + _index = at < 0 ? 0 : at + 1; // +1: row 0 is the toggle WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus()); } @@ -153,22 +152,38 @@ class _ThemePopoverState extends State<_ThemePopover> { super.dispose(); } + void _toggleHc() { + setState(() => _hc = !_hc); + // Re-apply the current base with the new variant, live; keep the popover open. + final base = baseThemeName(widget.controller.currentName); + widget.controller.select(resolveThemeName(widget.controller.available, base, highContrast: _hc)); + } + + void _pick(ThemeDefinition base) { + widget.controller.select(resolveThemeName(widget.controller.available, base.name, highContrast: _hc)); + widget.onClose(); + } + KeyEventResult _onKey(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored; - final themes = widget.controller.available; + final count = _themes.length + 1; // +1 toggle switch (event.logicalKey) { case LogicalKeyboardKey.arrowDown: - setState(() => _index = (_index + 1) % themes.length); + setState(() => _index = (_index + 1) % count); return KeyEventResult.handled; case LogicalKeyboardKey.arrowUp: - setState(() => _index = (_index - 1 + themes.length) % themes.length); + setState(() => _index = (_index - 1 + count) % count); return KeyEventResult.handled; case LogicalKeyboardKey.enter: case LogicalKeyboardKey.numpadEnter: - widget.onPick(themes[_index].name); + if (_index == 0) { + _toggleHc(); + } else { + _pick(_themes[_index - 1]); + } return KeyEventResult.handled; case LogicalKeyboardKey.escape: - widget.onDismiss(); + widget.onClose(); return KeyEventResult.handled; } return KeyEventResult.ignored; @@ -177,8 +192,8 @@ class _ThemePopoverState extends State<_ThemePopover> { @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; - final themes = widget.controller.available; - final currentName = widget.controller.currentName; + final themes = _themes; + final currentBase = baseThemeName(widget.controller.currentName); return Focus( focusNode: _focus, onKeyEvent: _onKey, @@ -187,25 +202,32 @@ class _ThemePopoverState extends State<_ThemePopover> { label: 'Theme switcher', explicitChildNodes: true, child: ClideSurface( - width: 240, + width: 280, color: tokens.modalSurfaceBackground, border: tokens.modalSurfaceBorder, padding: const EdgeInsets.all(4), borderRadius: BorderRadius.circular(4), child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 320), + constraints: const BoxConstraints(maxHeight: 360), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _HighContrastToggle( + checked: _hc, + highlighted: _index == 0, + onEnter: () => setState(() => _index = 0), + onTap: _toggleHc, + ), + ClideDivider(), for (var i = 0; i < themes.length; i++) _PopoverRow( displayName: themes[i].displayName, - selected: themes[i].name == currentName, - highlighted: i == _index, - onEnter: () => setState(() => _index = i), - onTap: () => widget.onPick(themes[i].name), + selected: themes[i].name == currentBase, + highlighted: _index == i + 1, + onEnter: () => setState(() => _index = i + 1), + onTap: () => _pick(themes[i]), ), ], ), @@ -217,6 +239,53 @@ class _ThemePopoverState extends State<_ThemePopover> { } } +/// The "High contrast" checkbox row at the top of the popover. +class _HighContrastToggle extends StatelessWidget { + const _HighContrastToggle({required this.checked, required this.highlighted, required this.onEnter, required this.onTap}); + + final bool checked; + final bool highlighted; + final VoidCallback onEnter; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + checked: checked, + label: 'High contrast', + excludeSemantics: true, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => onEnter(), + child: GestureDetector( + onTap: onTap, + child: Container( + color: highlighted ? tokens.listItemHoverBackground : tokens.listItemBackground, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Container( + width: 13, + height: 13, + decoration: BoxDecoration( + color: checked ? tokens.buttonBackground : null, + border: Border.all(color: checked ? tokens.buttonBackground : tokens.modalSurfaceBorder), + borderRadius: BorderRadius.circular(3), + ), + child: checked ? ClideIcon(const CheckIcon(), size: 9, color: tokens.buttonForeground) : null, + ), + const SizedBox(width: 8), + Expanded(child: ClideText('High contrast', color: tokens.listItemForeground, fontSize: clideFontCaption)), + ], + ), + ), + ), + ), + ); + } +} + class _PopoverRow extends StatelessWidget { const _PopoverRow({ required this.displayName, @@ -259,7 +328,7 @@ class _PopoverRow extends StatelessWidget { ) else const SizedBox(width: 17), - Expanded(child: ClideText(displayName, color: fg, fontSize: clideFontCaption)), + Expanded(child: ClideText(displayName, color: fg, fontSize: clideFontCaption, maxLines: 1, overflow: TextOverflow.ellipsis)), ], ), ), diff --git a/test/builtin/theme_picker/theme_families_test.dart b/test/builtin/theme_picker/theme_families_test.dart new file mode 100644 index 00000000..cfa5fbfd --- /dev/null +++ b/test/builtin/theme_picker/theme_families_test.dart @@ -0,0 +1,43 @@ +/// Tests for the theme-family helpers (T-237). +library; + +import 'package:clide/builtin/theme_picker/src/theme_families.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:test/test.dart'; + +ThemeDefinition _def(String name, String display) => ThemeDefinition(name: name, displayName: display, dark: true, palette: Palette(const {})); + +void main() { + final all = [ + _def('clide', 'Clide'), + _def('clide-hc', 'Clide (high contrast)'), + _def('midnight', 'Midnight'), + _def('midnight-hc', 'Midnight (high contrast)'), + _def('catppuccin-mocha', 'Catppuccin Mocha'), + _def('catppuccin-mocha-hc', 'Catppuccin Mocha (high contrast)'), + _def('paper', 'Paper'), // no -hc sibling in this fixture + ]; + + test('isHcName / baseThemeName', () { + expect(isHcName('midnight-hc'), isTrue); + expect(isHcName('midnight'), isFalse); + expect(baseThemeName('midnight-hc'), 'midnight'); + expect(baseThemeName('catppuccin-mocha-hc'), 'catppuccin-mocha'); + expect(baseThemeName('clide'), 'clide'); + }); + + test('baseThemes drops -hc/-cb and sorts by display name', () { + final bases = baseThemes(all).map((t) => t.name).toList(); + expect(bases, ['catppuccin-mocha', 'clide', 'midnight', 'paper']); // alphabetical by display + expect(bases.any(isHcName), isFalse); + }); + + test('hasHcSibling + resolveThemeName', () { + expect(hasHcSibling(all, 'midnight'), isTrue); + expect(hasHcSibling(all, 'paper'), isFalse); + expect(resolveThemeName(all, 'midnight', highContrast: true), 'midnight-hc'); + expect(resolveThemeName(all, 'midnight', highContrast: false), 'midnight'); + // No sibling → falls back to the base even when high contrast is on. + expect(resolveThemeName(all, 'paper', highContrast: true), 'paper'); + }); +} diff --git a/test/builtin/theme_picker/widget_test.dart b/test/builtin/theme_picker/widget_test.dart index 105ca237..19eb10b8 100644 --- a/test/builtin/theme_picker/widget_test.dart +++ b/test/builtin/theme_picker/widget_test.dart @@ -140,6 +140,23 @@ void main() { expect(find.bySemanticsLabel('forest'), findsNothing); }); + testWidgets('popover hides -hc rows; the High contrast toggle applies the sibling (T-237)', (tester) async { + final f2 = await KernelFixture.create(bundledThemes: [_def('midnight'), _def('midnight-hc')]); + addTearDown(f2.dispose); + await tester.pumpWidget(harness(f2, const ThemeSwitcherStatusItem())); + await tester.pump(); + await tester.tap(find.bySemanticsLabel('Theme: midnight')); + await tester.pumpAndSettle(); + // Base shown, -hc row hidden, toggle present. + expect(find.text('midnight'), findsOneWidget); + expect(find.text('midnight-hc'), findsNothing); + expect(find.text('High contrast'), findsOneWidget); + // Toggling high contrast applies the -hc sibling live. + await tester.tap(find.bySemanticsLabel('High contrast')); + await tester.pumpAndSettle(); + expect(f2.services.theme.currentName, 'midnight-hc'); + }); + testWidgets('Esc dismisses the popover without changing the theme (T-234)', (tester) async { await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem())); await tester.pump();