diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 9041c09d..994ebdad 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -116,3 +116,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, 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-234', 'status', 'ready', 'in_progress', NULL, '2026-06-03 14:27:26', '2026-06-03 14:27:26', '2026-06-03 14:27:26', NULL, '3db43a5f8b3855b2a78b4f6294dfeb97', 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-82', 'status', 'ready', 'in_progress', NULL, '2026-06-03 14:27:26', '2026-06-03 14:27:26', '2026-06-03 14:27:26', NULL, '7a7042a498a0f09a072d911fad8d33d7', 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', 'ready', 'in_progress', NULL, '2026-06-03 14:27:26', '2026-06-03 14:27:26', '2026-06-03 14:27:26', NULL, 'ec0e54053e51dea1fecb48eebc69e387', 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-82', 'status', 'in_progress', 'done', NULL, '2026-06-03 14:56:07', '2026-06-03 14:56:07', '2026-06-03 14:56:07', NULL, '9b160e380defb6a1ac78f200585745c8', 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-234', 'status', 'in_progress', 'done', NULL, '2026-06-03 15:04:07', '2026-06-03 15:04:07', '2026-06-03 15:04:07', NULL, 'a498a47a4c52685fb7a33cbf004b47f9', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index fa852936..4bf944b1 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -358,3 +358,30 @@ Acceptance: 3. Selecting a theme applies it live (ThemeController.select) and closes the popout. 4. Esc / outside-click dismisses without changing the theme; the control is keyboard-operable. 5. theme.pick (palette/command) still works unchanged.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-03 13:49:44', '2026-06-03 14:27:26', NULL, '9a5ac18d9e54e009f912b9c54584d796', 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-82', 'task', NULL, 'add Catppuccin theme to bundled themes', 'User request: ship a Catppuccin theme alongside the four bundled themes (clide, midnight, paper, terminal — D-44). + +**Acceptance:** +- New theme YAML at `lib/kernel/src/theme/themes/catppuccin.yaml` (or split for the four Catppuccin flavours: latte / frappe / macchiato / mocha — pick one or all four; recommend Mocha as the dark default and Latte for the light variant). +- Tokens populated for every key in `SurfaceTokens` (lib/kernel/src/theme/tokens.dart) — chrome, panels, tabs, list items, status colours, syntax tokens (D-45). +- WCAG-AA contrast gate (D-22) passes against the theme. +- Listed in `assets/licenses.yaml` with the upstream license (MIT) and attribution. +- Theme picker (`builtin.theme-picker`) shows it in the live-switch menu. + +**Source palettes:** https://github.com/catppuccin/catppuccin#-palette — official hex values, don`t paraphrase. + +**Why now:** test user request; also a good exercise for any future user-contributed theme since Catppuccin is the most common one to ask for.', 'done', 'low', NULL, NULL, NULL, '2026-05-06 08:30:15', '2026-06-03 14:56:07', NULL, '9cb0ab9e2c94f944809ee5abffdf802b', 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-234', 'story', NULL, 'Theme-switcher popout in the status bar', 'Add a theme switcher to the far-right corner of the bottom status bar (the empty area to the right of the ''application ok'' / ipc-status item — see the user''s screenshot). Today switching themes is only reachable via the theme.pick command / palette (ThemePickerExtension opens a modal picker_view); there''s no always-visible affordance. + +Add a compact status-bar control that opens a POPOUT (popover anchored to the control, not the full-screen modal) for quick theme switching: +- A small right-aligned status item — a palette/swatch icon (or the current theme name). Contribute via StatusItemContribution at Slots.statusbar with priority > 100 so it sits in the right group, to the right of the ipc/toolchain ''application ok'' item (priority 100). +- Click (or focus + Enter/Space) opens a popover listing ThemeController.available, the current (ThemeController.currentName) marked. +- Selecting an entry applies it immediately via ThemeController.select(name) and closes the popout. +- Esc / click-away dismisses with no change. Keyboard navigable (arrow + Enter), matching the palette/quick-open overlay pattern. +- Natural home: ThemePickerExtension (lib/builtin/theme_picker). Reuse the theme list + select logic from picker_view rather than duplicating; the modal theme.pick command stays as-is for parity (D-6). + +Acceptance: +1. A theme control sits in the far-right of the status bar. +2. Clicking/activating it opens a popover of all available themes with the current one marked. +3. Selecting a theme applies it live (ThemeController.select) and closes the popout. +4. Esc / outside-click dismisses without changing the theme; the control is keyboard-operable. +5. theme.pick (palette/command) still works unchanged.', 'done', 'medium', NULL, NULL, NULL, '2026-06-03 13:49:44', '2026-06-03 15:04:07', NULL, '88cfe20315c172dfdb9ba3ed1c25564d', 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/CHANGELOG.md b/CHANGELOG.md index 485ebe21..c0b189ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Theme switcher in the status bar: a far-right control showing the current + theme that opens a popover to switch live — click or keyboard (arrows/Enter, + Esc to dismiss). The `theme.pick` palette command is unchanged. (T-234) - Catppuccin Mocha theme, plus a high-contrast `catppuccin-mocha-hc` sibling, added to the bundled themes — switchable from the theme picker. Faithful to the official palette (D-69). (T-82) diff --git a/lib/builtin/theme_picker/src/extension.dart b/lib/builtin/theme_picker/src/extension.dart index 94c39629..376db4d6 100644 --- a/lib/builtin/theme_picker/src/extension.dart +++ b/lib/builtin/theme_picker/src/extension.dart @@ -1,5 +1,6 @@ import 'package:clide/clide.dart'; import 'package:clide/builtin/theme_picker/src/picker_view.dart'; +import 'package:clide/builtin/theme_picker/src/theme_status_item.dart'; import 'package:clide/extension/extension.dart'; class ThemePickerExtension extends ClideExtension { @@ -26,6 +27,14 @@ class ThemePickerExtension extends ClideExtension { defaultBinding: 'ctrl+k', run: _pick, ), + // Always-visible switcher in the far-right status bar (T-234). + // priority >= 100 places it in the right group; registered after + // ipc-status so it sits to its right. + StatusItemContribution( + id: 'theme-picker.switcher', + priority: 110, + build: (_) => const ThemeSwitcherStatusItem(), + ), ]; Future _pick(List args) async { diff --git a/lib/builtin/theme_picker/src/theme_status_item.dart b/lib/builtin/theme_picker/src/theme_status_item.dart new file mode 100644 index 00000000..a33f81b1 --- /dev/null +++ b/lib/builtin/theme_picker/src/theme_status_item.dart @@ -0,0 +1,270 @@ +/// Status-bar theme switcher (T-234). A compact right-aligned control +/// showing the current theme; activating it opens a popover (anchored +/// above the status bar, not a full-screen modal) listing every theme, +/// with live-apply on select. The modal `theme.pick` command is unchanged +/// (D-6 parity — both reach the same ThemeController). +library; + +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart' show KeyDownEvent, KeyRepeatEvent, LogicalKeyboardKey; + +class ThemeSwitcherStatusItem extends StatefulWidget { + const ThemeSwitcherStatusItem({super.key}); + + static const ns = 'builtin.theme-picker'; + + @override + State createState() => _ThemeSwitcherStatusItemState(); +} + +class _ThemeSwitcherStatusItemState extends State { + OverlayEntry? _entry; + bool get _open => _entry != null; + + ThemeController get _controller => ClideKernel.of(context).theme; + + void _toggle() => _open ? _close() : _openPopover(); + + void _close() { + _entry?.remove(); + _entry = null; + if (mounted) setState(() {}); + } + + void _openPopover() { + final overlay = Overlay.maybeOf(context); + final box = context.findRenderObject() as RenderBox?; + if (overlay == null || box == null) return; + final anchor = box.localToGlobal(Offset.zero); + final size = box.size; + final screen = MediaQuery.of(context).size; + + _entry = OverlayEntry( + builder: (ctx) { + // Anchor the popover's bottom-right to the control's top-right so it + // grows upward (the status bar lives at the window bottom). + final right = (screen.width - (anchor.dx + size.width)).clamp(0.0, screen.width); + return Stack( + children: [ + // Tap-away barrier — dismisses without changing the theme. + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _close, + ), + ), + Positioned( + right: right, + bottom: screen.height - anchor.dy + 4, + child: _ThemePopover( + controller: _controller, + onPick: (name) { + _controller.select(name); + _close(); + }, + onDismiss: _close, + ), + ), + ], + ); + }, + ); + overlay.insert(_entry!); + setState(() {}); + } + + @override + void dispose() { + _entry?.remove(); + _entry = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final label = _controller.current.displayName; + return Semantics( + button: true, + expanded: _open, + label: 'Theme: $label', + hint: 'Open the theme switcher', + excludeSemantics: true, + child: ClideTappable( + onTap: _toggle, + builder: (context, hovered, focused) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + color: (hovered || focused || _open) ? tokens.listItemHoverBackground : null, + 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), + ), + const SizedBox(width: 6), + ClideText(label, fontSize: clideFontCaption, color: tokens.statusBarForeground), + ], + ), + ), + ), + ); + }, + ); + } +} + +/// The popover body: a keyboard-navigable list of themes. Autofocuses so +/// arrows/Enter/Esc work immediately; the current theme starts highlighted. +class _ThemePopover extends StatefulWidget { + const _ThemePopover({required this.controller, required this.onPick, required this.onDismiss}); + + final ThemeController controller; + final void Function(String name) onPick; + final VoidCallback onDismiss; + + @override + State<_ThemePopover> createState() => _ThemePopoverState(); +} + +class _ThemePopoverState extends State<_ThemePopover> { + final _focus = FocusNode(debugLabel: 'ThemeSwitcher.popover'); + late int _index; + + @override + void initState() { + super.initState(); + final themes = widget.controller.available; + _index = themes.indexWhere((t) => t.name == widget.controller.currentName); + if (_index < 0) _index = 0; + WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus()); + } + + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored; + final themes = widget.controller.available; + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowDown: + setState(() => _index = (_index + 1) % themes.length); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowUp: + setState(() => _index = (_index - 1 + themes.length) % themes.length); + return KeyEventResult.handled; + case LogicalKeyboardKey.enter: + case LogicalKeyboardKey.numpadEnter: + widget.onPick(themes[_index].name); + return KeyEventResult.handled; + case LogicalKeyboardKey.escape: + widget.onDismiss(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final themes = widget.controller.available; + final currentName = widget.controller.currentName; + return Focus( + focusNode: _focus, + onKeyEvent: _onKey, + child: Semantics( + container: true, + label: 'Theme switcher', + explicitChildNodes: true, + child: ClideSurface( + width: 240, + color: tokens.modalSurfaceBackground, + border: tokens.modalSurfaceBorder, + padding: const EdgeInsets.all(4), + borderRadius: BorderRadius.circular(4), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _PopoverRow extends StatelessWidget { + const _PopoverRow({ + required this.displayName, + required this.selected, + required this.highlighted, + required this.onEnter, + required this.onTap, + }); + + final String displayName; + final bool selected; + final bool highlighted; + final VoidCallback onEnter; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final bg = selected ? tokens.listItemSelectedBackground : (highlighted ? tokens.listItemHoverBackground : tokens.listItemBackground); + final fg = selected ? tokens.listItemSelectedForeground : tokens.listItemForeground; + return Semantics( + button: true, + selected: selected, + label: displayName, + excludeSemantics: true, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => onEnter(), + child: GestureDetector( + onTap: onTap, + child: Container( + color: bg, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + if (selected) + Padding( + padding: const EdgeInsets.only(right: 6), + child: ClideIcon(const CheckIcon(), size: 11, color: fg), + ) + else + const SizedBox(width: 17), + Expanded(child: ClideText(displayName, color: fg, fontSize: clideFontCaption)), + ], + ), + ), + ), + ), + ); + } +} diff --git a/test/builtin/theme_picker/widget_test.dart b/test/builtin/theme_picker/widget_test.dart index 26a91122..105ca237 100644 --- a/test/builtin/theme_picker/widget_test.dart +++ b/test/builtin/theme_picker/widget_test.dart @@ -1,6 +1,8 @@ +import 'package:clide/builtin/theme_picker/src/theme_status_item.dart'; import 'package:clide/builtin/theme_picker/theme_picker.dart'; import 'package:clide/extension/extension.dart'; import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -112,6 +114,45 @@ void main() { expect(dismissed, isNull); }); + testWidgets('status switcher shows the active theme and opens a popover (T-234)', (tester) async { + await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem())); + await tester.pump(); + // Controller starts on the first bundled theme. + expect(f.services.theme.currentName, 'summer-night'); + expect(find.text('summer-night'), findsOneWidget); + + // Open the popover; it lists every theme (the other one appears). + await tester.tap(find.bySemanticsLabel('Theme: summer-night')); + await tester.pumpAndSettle(); + expect(find.text('forest'), findsOneWidget); + }); + + testWidgets('selecting in the popover applies live and closes (T-234)', (tester) async { + await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem())); + await tester.pump(); + await tester.tap(find.bySemanticsLabel('Theme: summer-night')); + await tester.pumpAndSettle(); + + await tester.tap(find.bySemanticsLabel('forest')); + await tester.pumpAndSettle(); + expect(f.services.theme.currentName, 'forest'); // applied live + // Popover closed: the only 'forest' left is the trigger label. + expect(find.bySemanticsLabel('forest'), findsNothing); + }); + + testWidgets('Esc dismisses the popover without changing the theme (T-234)', (tester) async { + await tester.pumpWidget(harness(f, const ThemeSwitcherStatusItem())); + await tester.pump(); + await tester.tap(find.bySemanticsLabel('Theme: summer-night')); + await tester.pumpAndSettle(); + expect(find.text('forest'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('forest'), findsNothing); // popover gone + expect(f.services.theme.currentName, 'summer-night'); // unchanged + }); + test('_pick before activate returns a not-activated error', () async { // Reach the command's run callback without going through activate — // _ctx is still null, so _pick hits the defensive error branch.