feat(settings): settings panel shell + settings.open command (T-445)

Lay the foundation for the schema-driven settings UI (epic T-444). A new
`settings.open` command (⌘`,`, plus a File-menu and command-palette entry)
opens a centered Settings modal over the dimmed app via the dialog router,
built from the modalSurface* tokens (D-7, no Material). The shell frames the
two regions later tickets fill in — the category rail (T-447) and the
scrolling carded panel (T-448) — and dismisses on ✕, Esc, or barrier tap.
With no category registered yet it shows its empty state, which is the
correct runtime state.

Flesh out the `builtin.settings-ui` stub (was 0.0.0-stub) into a real
extension; ship its en-US i18n catalog. Relabel the theme picker's
`theme.pick` command title from "Settings…" to "Theme…" so the two no
longer collide in the palette (the picker folds into the new panel's
Appearance category in T-452).

Tests: command + ⌘`,` binding registered, shell renders, Esc and close
both dismiss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:41:20 +02:00
co-authored by Claude Opus 4.8
parent 50d10c792a
commit 643f40d7b2
10 changed files with 317 additions and 9 deletions
@@ -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;
+1
View File
@@ -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);
+14
View File
@@ -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
+2
View File
@@ -75,6 +75,8 @@ List<TopMenu> 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),
],
),
+1
View File
@@ -1 +1,2 @@
export 'src/extension.dart';
export 'src/settings_modal.dart';
+32 -5
View File
@@ -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<String> get dependsOn => const [];
ClideExtensionContext? _ctx;
@override
List<ContributionPoint> get contributions => const [];
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
}
@override
List<ContributionPoint> 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<IpcResponse> _open(List<String> 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<Object>((context, dismiss) => SettingsModal(onDismiss: () => dismiss()));
return IpcResponse.ok(id: '', data: const {});
}
}
@@ -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,
),
),
);
}
}
+6 -4
View File
@@ -20,10 +20,12 @@ class ThemePickerExtension extends ClideExtension {
@override
List<ContributionPoint> 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.
@@ -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." }
}
+68
View File
@@ -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);
});
});
}