feat(settings): category rail + navigation (T-447)

The settings modal's left rail now lists the registered categories (icon +
title, data-driven from the SettingsRegistry) with an accent left-stripe +
surfaceHi selection per ui-design surface.md. Selecting a category drives the
modal's selection state and swaps the right panel; the rail scrolls when the
set exceeds the height. The cross-category search box atop the rail lands in
T-450.

Tests: rail lists categories and selecting one swaps the panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:09:56 +02:00
co-authored by Claude Opus 4.8
parent 4bbb0ee4b3
commit 6c6b0c731e
5 changed files with 122 additions and 15 deletions
@@ -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;
+2
View File
@@ -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);
+4
View File
@@ -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
+87 -15
View File
@@ -78,7 +78,13 @@ class _SettingsModalState extends State<SettingsModal> {
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)),
],
),
),
),
);
}
@@ -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);
});
});
}