diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index f6bb4b59..6bf96768 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5893,3 +5893,4 @@ It should be visually flagged with a **red error border** on the card so it stan **Acceptance:** an API error (rate-limit, server error, etc.) renders a card with a red `statusError` border; normal cards unchanged; a widget test asserts the error card''s border colour. **Files:** lib/builtin/claude/src/conversation_card.dart (border from error status), the error/system-message rendering in lib/builtin/claude/src/conversation_view.dart / transcript_reader.dart.', NULL, '2026-06-17 07:41:47', '2026-06-17 07:41:47.267', '2026-06-17 07:41:47.267', NULL, 'aa0330e9165b9b9fc3f1c516abc3a07c', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'status', 'backlog', 'done', NULL, '2026-06-17 09:21:55', '2026-06-17 09:21:55.041', '2026-06-17 09:21:55.041', NULL, 'cd1f735c94e8bca9adcd7d0f9cead676', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 2c2c03f9..41c0bee1 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7097,3 +7097,4 @@ It should be visually flagged with a **red error border** on the card so it stan **Acceptance:** an API error (rate-limit, server error, etc.) renders a card with a red `statusError` border; normal cards unchanged; a widget test asserts the error card''s border colour. **Files:** lib/builtin/claude/src/conversation_card.dart (border from error status), the error/system-message rendering in lib/builtin/claude/src/conversation_view.dart / transcript_reader.dart.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-17 07:41:24.975', '2026-06-17 07:41:47.266', NULL, 'e4c3db66fb4ba42ba08896b97ef3ad89', 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 ('06FD09C4A0HPZSP8HP44F7A894', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings modal shell + settings.open command + overlay', 'Centered modal over the dimmed app using modalSurface/modalOverlay tokens (D-7, no Material); new settings.open CommandContribution (palette + menu/hat entry); close button + Esc dismiss; hosts the category rail + scrolling carded panel; mounted in root_shell like the other overlays. Wireframe: docs/design/wireframes/settings/settings-screen.png (modal shell).', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:16:20', '2026-06-17 09:21:55.041', NULL, '82094d971c514c6524dd0c2197d171a4', 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 add3bb32..c9b828ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,20 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ## [Unreleased] +### Added + +- **Settings panel shell.** A new `settings.open` command (⌘`,`, plus a File + menu and command-palette entry) opens a centered Settings modal over the + dimmed app — the foundation of the schema-driven settings UI. It frames the + category rail and scrolling carded panel that later work fills in; closes on + ✕, Esc, or a barrier tap. (T-445) + +### Changed + +- **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title + changed from "Settings…" to "Theme…" so it no longer collides with the new + Settings panel in the palette; behaviour is unchanged. (T-445) + ## [2.6.0] — 2026-06-16 ### Added diff --git a/lib/builtin/menubar/src/extension.dart b/lib/builtin/menubar/src/extension.dart index 6ccc5943..bf3c5075 100644 --- a/lib/builtin/menubar/src/extension.dart +++ b/lib/builtin/menubar/src/extension.dart @@ -75,6 +75,8 @@ List buildClideMenuTree() => [ const MenuCommandItem('file.openFolder', fallbackTitle: 'Open Folder…'), const MenuCommandItem('file.newWindow', fallbackTitle: 'New Window'), const MenuSeparator(), + const MenuCommandItem('settings.open', fallbackTitle: 'Settings…'), + const MenuSeparator(), MenuCommandItem('file.closeWorkspace', fallbackTitle: 'Close Project', enabledWhen: (s) => s.project.isOpen), ], ), diff --git a/lib/builtin/settings_ui/settings_ui.dart b/lib/builtin/settings_ui/settings_ui.dart index b968b883..caeb269d 100644 --- a/lib/builtin/settings_ui/settings_ui.dart +++ b/lib/builtin/settings_ui/settings_ui.dart @@ -1 +1,2 @@ export 'src/extension.dart'; +export 'src/settings_modal.dart'; diff --git a/lib/builtin/settings_ui/src/extension.dart b/lib/builtin/settings_ui/src/extension.dart index 3c3e9950..f7953842 100644 --- a/lib/builtin/settings_ui/src/extension.dart +++ b/lib/builtin/settings_ui/src/extension.dart @@ -1,17 +1,44 @@ +import 'package:clide/builtin/settings_ui/src/settings_modal.dart'; +import 'package:clide/clide.dart'; import 'package:clide/extension/extension.dart'; -/// Tier-0 stub. Real implementation lands in a later tier; the extension -/// is registered so the extensions-ui surface can list it as "installed, -/// not yet implemented" and its id is reserved. +/// Schema-driven Settings UI (epic T-444). T-445 lands the foundation: the +/// `settings.open` command and the modal shell it opens. The category rail +/// (T-447), schema field renderer (T-448), scope tags (T-449), search +/// (T-450) and the per-subsystem categories fill the shell in later tickets. class SettingsUiExtension extends ClideExtension { @override String get id => 'builtin.settings-ui'; @override String get title => 'Settings UI'; @override - String get version => '0.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const []; + + ClideExtensionContext? _ctx; + @override - List get contributions => const []; + Future activate(ClideExtensionContext ctx) async { + _ctx = ctx; + } + + @override + List get contributions => [ + // Opens the Settings panel. Palette + File-menu entry come for free off + // the title; ctrl+, is the conventional settings shortcut. + CommandContribution(id: 'settings.open', command: 'settings.open', title: 'Settings…', defaultBinding: 'ctrl+,', run: _open), + ]; + + Future _open(List args) async { + final ctx = _ctx; + if (ctx == null) { + return IpcResponse.err( + id: '', + error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'settings-ui not activated'), + ); + } + await ctx.dialog.show((context, dismiss) => SettingsModal(onDismiss: () => dismiss())); + return IpcResponse.ok(id: '', data: const {}); + } } diff --git a/lib/builtin/settings_ui/src/settings_modal.dart b/lib/builtin/settings_ui/src/settings_modal.dart new file mode 100644 index 00000000..2b5e8f62 --- /dev/null +++ b/lib/builtin/settings_ui/src/settings_modal.dart @@ -0,0 +1,185 @@ +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// The schema-driven Settings panel shell (T-445, epic T-444). +/// +/// A centered modal over the dimmed app (hosted by [DialogHost] via +/// `ctx.dialog.show`), built from the `modalSurface*` tokens (D-7, no +/// Material). It frames the two regions the rest of the epic fills in: +/// +/// - the **category rail** on the left (navigation lands in T-447; the +/// list is data-driven from the schemas each subsystem registers), and +/// - the **scrolling carded panel** on the right (the schema field +/// renderer is T-448). +/// +/// Until any category registers a schema the panel shows its empty state — +/// that is the correct runtime state, not a placeholder. Dismiss with the +/// close button, Esc, or a barrier tap (the last handled by [DialogHost]). +/// +/// Wireframe: `docs/design/wireframes/settings/settings-screen.png`. +class SettingsModal extends StatelessWidget { + const SettingsModal({super.key, required this.onDismiss}); + + /// Closes the modal. Wired to the dialog router's `dismiss` by the + /// opener (see `SettingsUiExtension`). + final VoidCallback onDismiss; + + static const ns = 'builtin.settings-ui'; + + // The modal is a fixed-size desktop surface (the app is a desktop host); + // the right panel scrolls when its cards exceed the height (surface.md). + static const double _width = 760; + static const double _height = 560; + static const double _railWidth = 196; + + @override + Widget build(BuildContext context) { + final i = ClideKernel.of(context).i18n; + final tokens = ClideTheme.of(context).surface; + final title = i.string('modal.title', namespace: ns, placeholder: 'Settings'); + + return Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { + onDismiss(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Semantics( + container: true, + label: title, + explicitChildNodes: true, + child: ClideSurface( + width: _width, + height: _height, + color: tokens.modalSurfaceBackground, + border: tokens.modalSurfaceBorder, + borderRadius: BorderRadius.circular(6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header(title: title, onClose: onDismiss), + const ClideDivider(), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: _railWidth, child: const _CategoryRail()), + const ClideDivider(axis: Axis.vertical), + const Expanded(child: _SettingsPanel()), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Title bar: gear glyph + "Settings" on the left, close ✕ on the right. +class _Header extends StatelessWidget { + const _Header({required this.title, required this.onClose}); + + final String title; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 10, 12), + child: Row( + children: [ + ClideIcon(const GearIcon(), size: 16, color: tokens.globalForeground), + const SizedBox(width: 8), + Expanded(child: ClideText(title, fontSize: 15, fontWeight: FontWeight.w600)), + _CloseButton(onTap: onClose), + ], + ), + ); + } +} + +class _CloseButton extends StatelessWidget { + const _CloseButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final i = ClideKernel.of(context).i18n; + final label = i.string('modal.close', namespace: SettingsModal.ns, placeholder: 'Close'); + final hint = i.string('modal.close.hint', namespace: SettingsModal.ns, placeholder: 'Close settings'); + return Semantics( + button: true, + label: label, + hint: hint, + onTap: onTap, + excludeSemantics: true, + child: ClideTappable( + cursor: SystemMouseCursors.click, + onTap: onTap, + builder: (ctx, hovered, pressed) => Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)), + child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground), + ), + ), + ); + } +} + +/// Left rail. The category list is populated from registered schemas in +/// T-447; for now it shows only its section header. +class _CategoryRail extends StatelessWidget { + const _CategoryRail(); + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final i = ClideKernel.of(context).i18n; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideText( + i.string('rail.header', namespace: SettingsModal.ns, placeholder: 'Categories'), + fontSize: clideFontCaption, + color: tokens.sidebarSectionHeader, + fontFamily: clideMonoFamily, + ), + ], + ), + ); + } +} + +/// Right panel. The schema-driven field renderer fills this in T-448; with +/// no category registered yet it shows the empty state. +class _SettingsPanel extends StatelessWidget { + const _SettingsPanel(); + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final i = ClideKernel.of(context).i18n; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: ClideText( + i.string('panel.empty', namespace: SettingsModal.ns, placeholder: 'No settings categories are registered yet.'), + color: tokens.globalTextMuted, + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/lib/builtin/theme_picker/src/extension.dart b/lib/builtin/theme_picker/src/extension.dart index 6bc0eb01..a2b4462f 100644 --- a/lib/builtin/theme_picker/src/extension.dart +++ b/lib/builtin/theme_picker/src/extension.dart @@ -20,10 +20,12 @@ class ThemePickerExtension extends ClideExtension { @override List get contributions => [ - // Opens the settings modal (T-238). Command id kept as `theme.pick` - // (the welcome theme-link and other callers reference it); ⌘K opens - // Settings, whose only section today is the theme picker. - CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Settings…', defaultBinding: 'ctrl+k', run: _pick), + // Opens the theme picker modal (T-238). Command id kept as `theme.pick` + // (the welcome theme-link and other callers reference it). Titled + // "Theme…" to disambiguate from the schema-driven Settings panel's + // `settings.open` (T-444); the theme picker folds into that panel's + // Appearance category in T-452. + CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Theme…', 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. diff --git a/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json b/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json new file mode 100644 index 00000000..b8a3a43b --- /dev/null +++ b/lib/kernel/src/i18n/catalog/builtin.settings-ui_en_us.json @@ -0,0 +1,7 @@ +{ + "modal.title": { "translation": "Settings" }, + "modal.close": { "translation": "Close" }, + "modal.close.hint": { "translation": "Close settings without changing anything" }, + "rail.header": { "translation": "Categories" }, + "panel.empty": { "translation": "No settings categories are registered yet." } +} diff --git a/test/builtin/settings_ui/widget_test.dart b/test/builtin/settings_ui/widget_test.dart new file mode 100644 index 00000000..3d48ff32 --- /dev/null +++ b/test/builtin/settings_ui/widget_test.dart @@ -0,0 +1,68 @@ +import 'package:clide/builtin/settings_ui/settings_ui.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter/services.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() { + group('SettingsUiExtension', () { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create( + i18nCatalogs: { + 'builtin.settings-ui': { + const Locale('en', 'US'): const { + 'modal.title': {'translation': 'Settings'}, + 'modal.close': {'translation': 'Close'}, + 'modal.close.hint': {'translation': 'Close settings'}, + 'rail.header': {'translation': 'Categories'}, + 'panel.empty': {'translation': 'No settings categories are registered yet.'}, + }, + }, + }, + ); + }); + + tearDown(() async => f.dispose()); + + test('contributes a settings.open command', () async { + f.services.extensions.register(SettingsUiExtension()); + await f.services.extensions.activateAll(); + expect(f.services.commands.get('settings.open'), isNotNull); + }); + + test('default binding ctrl+, is registered', () async { + f.services.extensions.register(SettingsUiExtension()); + await f.services.extensions.activateAll(); + expect(f.services.keybindings.commandFor(Keybinding.parse('ctrl+,')), 'settings.open'); + }); + + testWidgets('modal shell renders title, rail header, and empty state', (tester) async { + await tester.pumpWidget(harness(f, SettingsModal(onDismiss: () {}))); + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Categories'), findsOneWidget); + expect(find.text('No settings categories are registered yet.'), findsOneWidget); + }); + + testWidgets('Esc dismisses the modal', (tester) async { + var dismissed = 0; + await tester.pumpWidget(harness(f, SettingsModal(onDismiss: () => dismissed++))); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(dismissed, 1); + }); + + testWidgets('close button dismisses the modal', (tester) async { + var dismissed = 0; + await tester.pumpWidget(harness(f, SettingsModal(onDismiss: () => dismissed++))); + // The close button carries the "Close" semantics label. + await tester.tap(find.bySemanticsLabel('Close')); + await tester.pump(); + expect(dismissed, 1); + }); + }); +}