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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user