diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index bf69082a..d03cb697 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -5900,3 +5900,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 ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'status', 'backlog', 'ready', NULL, '2026-06-17 09:50:53', '2026-06-17 09:50:53.074', '2026-06-17 09:50:53.074', NULL, 'aa94551043d54ff96acad1054a095b5b', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:06:59', '2026-06-17 10:06:59.713', '2026-06-17 10:06:59.713', NULL, '8d93c1cb133b480fdd5e8407977df764', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:07:00', '2026-06-17 10:07:00.144', '2026-06-17 10:07:00.144', NULL, '18b1ae31f194073a8f6dbb7ca9d58963', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'status', 'in_progress', 'done', NULL, '2026-06-17 10:09:41', '2026-06-17 10:09:41.725', '2026-06-17 10:09:41.725', NULL, 'af6eaeeae0e9e27a80ad26f9ab78c7c2', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'status', 'ready', 'in_progress', NULL, '2026-06-17 10:09:41', '2026-06-17 10:09:41.911', '2026-06-17 10:09:41.911', NULL, '5588d681ec1d926adbcdc378ef37eb47', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 9305cef2..7338cfd7 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7168,3 +7168,5 @@ Open question to settle in this task: do framework widgets get a ''core''/''kern Acceptance: real user-facing sentences in lib/widgets + lib/kernel resolve through i18n under a documented namespace; glyph exemptions noted.', 'backlog', 'low', NULL, NULL, 'D-21', '2026-06-17 09:57:53.221', '2026-06-17 09:57:53.221', NULL, 'f284696fc306e1e2452f89c5fa8b7057', 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 ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Schema-driven settings field renderer (the engine)', 'The core engine: render carded sections of field rows from a subsystem schema registered against the kernel SettingsStore — toggle, enum/select, text/number, and opens-external-file affordance — each with label, help text, default value, and reset-to-default; writes edits back to the resolved scope''s settings file. Categories just register schemas; this draws them. Carded-section layout per ui-design surface.md. Wireframe: settings-screen.png (Editor).', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:20', '2026-06-17 10:06:59.713', NULL, 'f0a3bc1d6ab084ce36fb529cadca2d52', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:07:00.144', NULL, '67beda85d5d212fb1f3493e575a2d338', 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 ('06FD09SHDMGT66C4D1DRZQZ7RR', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Settings category rail + navigation/selection', 'Left rail listing categories (Editor, Keymap, Appearance, Claude, Activity, Terminal, Extensions) with the accent left-stripe + surfaceHi selection; selecting a category swaps the panel; the category set is derived from the registered schemas (data-driven, not hardcoded); the search box sits atop the rail. Wireframe: settings-screen.png / settings-claude.png rail.', 'done', 'high', NULL, NULL, NULL, '2026-06-16 11:18:10', '2026-06-17 10:09:41.725', NULL, 'ccd735c9480310dd9a849881c39ce100', 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 ('06FD09WB2XSRP0AZ204FDP681M', 'task', '06FD07ECS3GK2V7Z2WYYPJHJYC', 'Per-field scope-tag control + scope resolution', 'Per-field scope icon — folder=Project (.clide), globe=Always (~/.clide), circle-dashed=Default/unset — via PhosphorIcons.byName(''folder''|''globe''|''circle-dashed'') (T-314, no consts), colour-coded with a tooltip; tap opens a scope menu to move the value between scopes. Resolves the effective value across scope layers and writes the edit to the chosen scope''s file. Scope-tag decision: T-302. Wireframe legend on settings-screen.png.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-16 11:18:32', '2026-06-17 10:09:41.911', NULL, 'db4e636e73d5ca1486f9416f47515e03', 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 3cc9cf68..7ea73f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. `panelBackground` inside the elevated `panelHeader` cards (ui-design surface.md). Categories are data; registering one surfaces a new tab. (T-448) +- **Settings category rail.** The modal's left rail lists the registered + categories (icon + title, data-driven from the registry) with an accent + left-stripe + surfaceHi selection; picking one swaps the panel. (T-447) + ### Changed - **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title diff --git a/lib/builtin/settings_ui/src/settings_modal.dart b/lib/builtin/settings_ui/src/settings_modal.dart index 15611ffe..f68d6f18 100644 --- a/lib/builtin/settings_ui/src/settings_modal.dart +++ b/lib/builtin/settings_ui/src/settings_modal.dart @@ -78,7 +78,13 @@ class _SettingsModalState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(width: SettingsModal._railWidth, child: _CategoryRail()), + SizedBox( + width: SettingsModal._railWidth, + child: _CategoryRail( + selectedId: _selectedId, + onSelect: (id) => setState(() => _selectedId = id), + ), + ), const ClideDivider(axis: Axis.vertical), Expanded(child: _SettingsPanel(selectedId: _selectedId)), ], @@ -149,27 +155,93 @@ class _CloseButton extends StatelessWidget { } } -/// Left rail. The interactive category list lands in T-447; for now it shows -/// only its section header. +/// Left rail — the registered categories, data-driven from the +/// [SettingsRegistry]. Selecting one swaps the panel (T-447). The cross-category +/// search box sits atop the rail in T-450. class _CategoryRail extends StatelessWidget { - const _CategoryRail(); + const _CategoryRail({required this.selectedId, required this.onSelect}); + + /// The modal's chosen category id (null → the first category). + final String? selectedId; + final void Function(String id) onSelect; @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, + final registry = ClideKernel.of(context).settingsRegistry; + return ListenableBuilder( + listenable: registry, + builder: (context, _) { + final categories = registry.categories; + final effectiveId = selectedId ?? (categories.isEmpty ? null : categories.first.id); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 6), + child: ClideText( + i.string('rail.header', namespace: SettingsModal.ns, placeholder: 'Categories'), + fontSize: clideFontCaption, + color: tokens.sidebarSectionHeader, + fontFamily: clideMonoFamily, + ), + ), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final c in categories) + _RailRow(category: c, selected: c.id == effectiveId, onTap: () => onSelect(c.id)), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +/// One category row: optional glyph + title, accent left-stripe + surfaceHi +/// fill when selected (surface.md side panels). +class _RailRow extends StatelessWidget { + const _RailRow({required this.category, required this.selected, required this.onTap}); + + final SettingsCategory category; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + final fg = selected ? tokens.globalForeground : tokens.sidebarForeground; + return Semantics( + button: true, + selected: selected, + label: category.title, + excludeSemantics: true, + child: ClideTappable( + cursor: SystemMouseCursors.click, + onTap: onTap, + builder: (ctx, hovered, _) => Container( + decoration: BoxDecoration( + color: selected ? tokens.sidebarItemSelected : (hovered ? tokens.sidebarItemHover : null), + border: Border(left: BorderSide(color: selected ? tokens.panelActiveBorder : const Color(0x00000000), width: 2)), ), - ], + padding: const EdgeInsets.fromLTRB(12, 7, 12, 7), + child: Row( + children: [ + if (category.iconName != null) ...[ + ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg), + const SizedBox(width: 8), + ], + Expanded(child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis)), + ], + ), + ), ), ); } diff --git a/test/builtin/settings_ui/category_view_test.dart b/test/builtin/settings_ui/category_view_test.dart index f7a670bc..0841fdb6 100644 --- a/test/builtin/settings_ui/category_view_test.dart +++ b/test/builtin/settings_ui/category_view_test.dart @@ -30,6 +30,17 @@ const _category = SettingsCategory( ], ); +const _other = SettingsCategory( + id: 'other', + title: 'Other', + sections: [ + SettingsSection( + label: 'Misc', + fields: [SettingsField(key: 'app.other.x', kind: SettingsFieldKind.toggle, label: 'OtherFlag', defaultValue: false)], + ), + ], +); + /// Tiny extension that registers [_category] via the contribution. class _DemoSettingsExt extends ClideExtension { @override @@ -109,5 +120,21 @@ void main() { expect(find.text('Flag'), findsOneWidget); expect(find.text('No settings categories are registered yet.'), findsNothing); }); + + testWidgets('rail lists categories and selecting one swaps the panel', (tester) async { + f.services.settingsRegistry.register(_category); + f.services.settingsRegistry.register(_other); + await tester.pumpWidget(harness(f, SettingsModal(onDismiss: () {}))); + // Rail shows both titles; first (alphabetical) category's panel is shown. + expect(find.text('Demo'), findsOneWidget); + expect(find.text('Other'), findsOneWidget); + expect(find.text('Flag'), findsOneWidget); + expect(find.text('OtherFlag'), findsNothing); + // Selecting the second category swaps the panel. + await tester.tap(find.text('Other')); + await tester.pump(); + expect(find.text('OtherFlag'), findsOneWidget); + expect(find.text('Flag'), findsNothing); + }); }); }