@toolr/ui-design 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/components/lib/theme-engine.ts +48 -15
  2. package/components/sections/ai-tools-paths/tools-paths-panel.tsx +11 -11
  3. package/components/sections/captured-issues/captured-issues-panel.tsx +20 -20
  4. package/components/sections/golden-snapshots/file-diff-viewer.tsx +19 -19
  5. package/components/sections/golden-snapshots/golden-sync-panel.tsx +3 -3
  6. package/components/sections/golden-snapshots/snapshot-manager.tsx +15 -15
  7. package/components/sections/golden-snapshots/status-overview.tsx +40 -40
  8. package/components/sections/golden-snapshots/version-manager.tsx +10 -10
  9. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +11 -11
  10. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +15 -15
  11. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +19 -19
  12. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +10 -10
  13. package/components/sections/snapshot-browser/snapshot-tree.tsx +11 -11
  14. package/components/sections/snippets-editor/snippets-editor.tsx +24 -24
  15. package/components/settings/SettingsHeader.tsx +78 -0
  16. package/components/settings/SettingsPanel.tsx +21 -0
  17. package/components/settings/SettingsTreeNav.tsx +256 -0
  18. package/components/settings/index.ts +7 -0
  19. package/components/settings/settings-tree-utils.ts +120 -0
  20. package/components/ui/breadcrumb.tsx +16 -4
  21. package/components/ui/cookie-consent.tsx +82 -0
  22. package/components/ui/file-tree.tsx +5 -5
  23. package/components/ui/filter-dropdown.tsx +4 -4
  24. package/components/ui/form-actions.tsx +1 -1
  25. package/components/ui/label.tsx +31 -3
  26. package/components/ui/resizable-textarea.tsx +2 -2
  27. package/components/ui/segmented-toggle.tsx +17 -4
  28. package/components/ui/select.tsx +3 -3
  29. package/components/ui/sort-dropdown.tsx +2 -2
  30. package/components/ui/status-card.tsx +1 -1
  31. package/components/ui/tooltip.tsx +2 -2
  32. package/dist/index.d.ts +79 -8
  33. package/dist/index.js +1119 -622
  34. package/dist/tokens/{tokens/primitives.css → primitives.css} +10 -8
  35. package/dist/tokens/{tokens/semantic.css → semantic.css} +5 -0
  36. package/index.ts +13 -0
  37. package/package.json +6 -2
  38. package/tokens/primitives.css +10 -8
  39. package/tokens/semantic.css +5 -0
  40. /package/dist/tokens/{tokens/theme.css → theme.css} +0 -0
  41. /package/dist/tokens/{tokens/tokens.json → tokens.json} +0 -0
@@ -194,14 +194,14 @@ function SnapshotEntryRow({
194
194
 
195
195
  return (
196
196
  <div
197
- className="flex items-center gap-2 px-2 py-1.5 text-sm rounded-md group transition-colors hover:bg-[#313244]/50 text-[#a6adc8]"
197
+ className="flex items-center gap-2 px-2 py-1.5 text-sm rounded-md group transition-colors hover:bg-neutral-700/50 text-neutral-400"
198
198
  style={{ paddingLeft: `${depth * 16 + 8}px` }}
199
199
  >
200
- <Clock className="w-3 h-3 shrink-0 text-[#6c7086]" />
200
+ <Clock className="w-3 h-3 shrink-0 text-neutral-500" />
201
201
  <span className="text-xs flex-1 truncate">
202
202
  {searchQuery ? highlightMatch(displayName, searchQuery) : displayName}
203
203
  </span>
204
- <span className="text-[10px] text-[#6c7086] shrink-0" title={formatFullDate(entry.savedAt)}>
204
+ <span className="text-[10px] text-neutral-500 shrink-0" title={formatFullDate(entry.savedAt)}>
205
205
  {formatRelativeTime(entry.savedAt)}
206
206
  </span>
207
207
  <IconButton
@@ -248,7 +248,7 @@ function ExpandableNode({
248
248
  <div>
249
249
  <button
250
250
  onClick={() => onToggle(path)}
251
- className="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer text-[#a6adc8] hover:bg-[#313244]/50 hover:text-[#cdd6f4]"
251
+ className="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer text-neutral-400 hover:bg-neutral-700/50 hover:text-neutral-300"
252
252
  style={{ paddingLeft: `${depth * 16 + 8}px` }}
253
253
  >
254
254
  <span className="w-4 h-4 flex items-center justify-center shrink-0">
@@ -266,7 +266,7 @@ function ExpandableNode({
266
266
  </span>
267
267
 
268
268
  {snapshotCount > 0 && (
269
- <span className="text-xs text-[#6c7086] bg-[#313244] px-1.5 py-0.5 rounded shrink-0">
269
+ <span className="text-xs text-neutral-500 bg-neutral-700 px-1.5 py-0.5 rounded shrink-0">
270
270
  {snapshotCount}
271
271
  </span>
272
272
  )}
@@ -322,7 +322,7 @@ export function SnapshotTree({
322
322
 
323
323
  if (totalCount === 0) {
324
324
  return (
325
- <div className={cn('text-sm text-[#6c7086] py-8 text-center', className)}>
325
+ <div className={cn('text-sm text-neutral-500 py-8 text-center', className)}>
326
326
  No snapshots saved yet.
327
327
  <br />
328
328
  <span className="text-xs">Use the camera button in editors to save snapshots.</span>
@@ -335,7 +335,7 @@ export function SnapshotTree({
335
335
  {/* Search and controls */}
336
336
  <div className="flex items-center gap-2">
337
337
  <div className="relative flex-1">
338
- <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#6c7086] pointer-events-none" />
338
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-500 pointer-events-none" />
339
339
  <Input
340
340
  placeholder="Search snapshots..."
341
341
  value={searchQuery}
@@ -375,9 +375,9 @@ export function SnapshotTree({
375
375
  </div>
376
376
 
377
377
  {/* Tree */}
378
- <div className="bg-[#181825] border border-[#313244] rounded-lg p-2 min-h-[200px] max-h-[60vh] overflow-y-auto">
378
+ <div className="bg-neutral-900 border border-neutral-700 rounded-lg p-2 min-h-[200px] max-h-[60vh] overflow-y-auto">
379
379
  {filteredScopes.length === 0 && searchQuery ? (
380
- <p className="text-xs text-[#6c7086] text-center py-4">
380
+ <p className="text-xs text-neutral-500 text-center py-4">
381
381
  No snapshots match &ldquo;{searchQuery}&rdquo;
382
382
  </p>
383
383
  ) : (
@@ -401,7 +401,7 @@ export function SnapshotTree({
401
401
  label={category.name}
402
402
  icon={
403
403
  category.icon ? (
404
- <History className={cn('w-3.5 h-3.5', CATEGORY_ICON_COLORS[category.icon] || 'text-[#6c7086]')} />
404
+ <History className={cn('w-3.5 h-3.5', CATEGORY_ICON_COLORS[category.icon] || 'text-neutral-500')} />
405
405
  ) : undefined
406
406
  }
407
407
  snapshotCount={countCategorySnapshots(category)}
@@ -416,7 +416,7 @@ export function SnapshotTree({
416
416
  id={item.id}
417
417
  path={`${scope.id}/${category.id}/${item.id}`}
418
418
  label={item.name}
419
- icon={<History className="w-3.5 h-3.5 text-[#6c7086]" />}
419
+ icon={<History className="w-3.5 h-3.5 text-neutral-500" />}
420
420
  snapshotCount={item.snapshots.length}
421
421
  depth={2}
422
422
  isExpanded={expandedPaths.has(`${scope.id}/${category.id}/${item.id}`)}
@@ -98,25 +98,25 @@ export function SnippetsEditor({
98
98
  )
99
99
 
100
100
  return (
101
- <div className={cn('flex flex-col bg-[#181825] border border-[#313244] rounded-lg overflow-hidden', className)}>
101
+ <div className={cn('flex flex-col bg-neutral-900 border border-neutral-700 rounded-lg overflow-hidden', className)}>
102
102
  {/* Header */}
103
- <div className="flex items-center justify-between px-4 py-3 border-b border-[#313244] bg-purple-500/5">
103
+ <div className="flex items-center justify-between px-4 py-3 border-b border-neutral-700 bg-purple-500/5">
104
104
  <div className="flex items-center gap-2">
105
105
  <Braces className="w-4 h-4 text-purple-400" />
106
- <h3 className="text-sm font-medium text-[#cdd6f4]">{title}</h3>
107
- <span className="px-2 py-0.5 text-xs rounded-full bg-[#313244] text-[#a6adc8]">
106
+ <h3 className="text-sm font-medium text-neutral-300">{title}</h3>
107
+ <span className="px-2 py-0.5 text-xs rounded-full bg-neutral-700 text-neutral-400">
108
108
  {snippets.length}
109
109
  </span>
110
110
  </div>
111
- <p className="text-xs text-[#6c7086] hidden sm:block">{description}</p>
111
+ <p className="text-xs text-neutral-500 hidden sm:block">{description}</p>
112
112
  </div>
113
113
 
114
114
  {/* Body: two columns */}
115
115
  <div className="flex flex-1 min-h-[400px]">
116
116
  {/* Left: Snippet list */}
117
- <div className="flex flex-col border-r border-[#313244]" style={{ width: sidebarWidth, minWidth: MIN_SIDEBAR }}>
117
+ <div className="flex flex-col border-r border-neutral-700" style={{ width: sidebarWidth, minWidth: MIN_SIDEBAR }}>
118
118
  {/* Search + Add */}
119
- <div className="flex items-center gap-1.5 p-2 border-b border-[#313244]">
119
+ <div className="flex items-center gap-1.5 p-2 border-b border-neutral-700">
120
120
  <div className="flex-1">
121
121
  <Input
122
122
  type="search"
@@ -141,10 +141,10 @@ export function SnippetsEditor({
141
141
  {filteredSnippets.length === 0 && !isAdding && (
142
142
  <div className="text-center py-10 px-4">
143
143
  <Braces className="w-8 h-8 mx-auto text-purple-400/40 mb-3" />
144
- <p className="text-xs text-[#6c7086] mb-1">
144
+ <p className="text-xs text-neutral-500 mb-1">
145
145
  {searchQuery ? 'No matching snippets' : 'No snippets defined'}
146
146
  </p>
147
- <p className="text-[10px] text-[#45475a]">
147
+ <p className="text-[10px] text-neutral-600">
148
148
  {searchQuery ? 'Try a different search term' : 'Click + to add your first snippet'}
149
149
  </p>
150
150
  </div>
@@ -186,8 +186,8 @@ export function SnippetsEditor({
186
186
  <div className="flex-1 flex items-center justify-center p-6">
187
187
  <div className="text-center max-w-xs">
188
188
  <Braces className="w-10 h-10 mx-auto text-purple-400/30 mb-4" />
189
- <p className="text-sm text-[#6c7086] mb-2">Select a snippet to edit</p>
190
- <p className="text-xs text-[#45475a] leading-relaxed">
189
+ <p className="text-sm text-neutral-500 mb-2">Select a snippet to edit</p>
190
+ <p className="text-xs text-neutral-600 leading-relaxed">
191
191
  Choose a snippet from the list, or click{' '}
192
192
  <span className="text-blue-400">+</span> to create a new one.
193
193
  Reference snippets in prompts with{' '}
@@ -219,20 +219,20 @@ function SnippetListItem({ snippet, selected, onSelect, onDelete }: SnippetListI
219
219
  className={cn(
220
220
  'group flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors border-l-2',
221
221
  selected
222
- ? 'bg-[#1e1e2e] border-l-[#89b4fa]'
223
- : 'border-l-transparent hover:bg-[#1e1e2e]/50',
222
+ ? 'bg-neutral-850 border-l-blue-400'
223
+ : 'border-l-transparent hover:bg-neutral-850/50',
224
224
  )}
225
225
  onClick={onSelect}
226
226
  >
227
227
  <div className="flex-1 min-w-0">
228
- <p className="text-xs font-mono font-medium text-[#cdd6f4] truncate">
228
+ <p className="text-xs font-mono font-medium text-neutral-300 truncate">
229
229
  {snippet.name}
230
230
  </p>
231
- <p className="text-[10px] text-[#6c7086] truncate mt-0.5">
231
+ <p className="text-[10px] text-neutral-500 truncate mt-0.5">
232
232
  {snippet.description}
233
233
  </p>
234
234
  {snippet.value && (
235
- <p className="text-[10px] text-[#45475a] truncate mt-0.5 font-mono">
235
+ <p className="text-[10px] text-neutral-600 truncate mt-0.5 font-mono">
236
236
  {snippet.value.slice(0, 80)}{snippet.value.length > 80 ? '...' : ''}
237
237
  </p>
238
238
  )}
@@ -240,7 +240,7 @@ function SnippetListItem({ snippet, selected, onSelect, onDelete }: SnippetListI
240
240
  <button
241
241
  type="button"
242
242
  onClick={(e) => { e.stopPropagation(); onDelete() }}
243
- className="opacity-0 group-hover:opacity-100 mt-0.5 p-0.5 rounded text-[#6c7086] hover:text-red-400 hover:bg-red-500/10 transition-all"
243
+ className="opacity-0 group-hover:opacity-100 mt-0.5 p-0.5 rounded text-neutral-500 hover:text-red-400 hover:bg-red-500/10 transition-all"
244
244
  >
245
245
  <X className="w-3 h-3" />
246
246
  </button>
@@ -282,7 +282,7 @@ function SnippetForm({
282
282
  <div className="flex-1 overflow-y-auto p-4 space-y-4">
283
283
  {/* Name */}
284
284
  <div>
285
- <label className="block text-xs text-[#6c7086] mb-1.5">
285
+ <label className="block text-xs text-neutral-500 mb-1.5">
286
286
  Snippet Name <span className="text-red-400">*</span>
287
287
  </label>
288
288
  <Input
@@ -292,14 +292,14 @@ function SnippetForm({
292
292
  error={nameHasError}
293
293
  autoFocus={!isEditing}
294
294
  />
295
- <p className="mt-1 text-[10px] text-[#45475a]">
295
+ <p className="mt-1 text-[10px] text-neutral-600">
296
296
  Use in prompts as <span className="font-mono text-purple-400">{'{{' + (formData.name || 'NAME') + '}}'}</span>
297
297
  </p>
298
298
  </div>
299
299
 
300
300
  {/* Description */}
301
301
  <div>
302
- <label className="block text-xs text-[#6c7086] mb-1.5">
302
+ <label className="block text-xs text-neutral-500 mb-1.5">
303
303
  Description <span className="text-red-400">*</span>
304
304
  </label>
305
305
  <Input
@@ -311,7 +311,7 @@ function SnippetForm({
311
311
 
312
312
  {/* Value */}
313
313
  <div>
314
- <label className="block text-xs text-[#6c7086] mb-1.5">Value</label>
314
+ <label className="block text-xs text-neutral-500 mb-1.5">Value</label>
315
315
  <ResizableTextarea
316
316
  mode="code"
317
317
  language="markdown"
@@ -319,7 +319,7 @@ function SnippetForm({
319
319
  onChange={(val) => setFormField('value', val)}
320
320
  minHeight={160}
321
321
  />
322
- <p className="mt-1 text-[10px] text-[#45475a]">
322
+ <p className="mt-1 text-[10px] text-neutral-600">
323
323
  Can be a single value, multi-line text, or an entire document
324
324
  </p>
325
325
  </div>
@@ -333,7 +333,7 @@ function SnippetForm({
333
333
  </div>
334
334
 
335
335
  {/* Footer actions */}
336
- <div className="flex items-center justify-between gap-2 border-t border-[#313244] px-4 py-3">
336
+ <div className="flex items-center justify-between gap-2 border-t border-neutral-700 px-4 py-3">
337
337
  <div>
338
338
  {onDelete && (
339
339
  <IconButton
@@ -352,7 +352,7 @@ function SnippetForm({
352
352
  type="button"
353
353
  onClick={onCancel}
354
354
  disabled={isSaving}
355
- className="rounded-md border border-[#313244] bg-transparent px-3 py-1.5 text-xs text-[#a6adc8] transition-colors hover:bg-[#313244] hover:text-[#cdd6f4] disabled:opacity-50"
355
+ className="rounded-md border border-neutral-700 bg-transparent px-3 py-1.5 text-xs text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-neutral-300 disabled:opacity-50"
356
356
  >
357
357
  Cancel
358
358
  </button>
@@ -0,0 +1,78 @@
1
+ import { type ReactNode } from 'react'
2
+ import { RotateCcw, Info } from 'lucide-react'
3
+ import { IconButton } from '../ui/icon-button.tsx'
4
+
5
+ export interface SettingsHeaderProps {
6
+ description: string
7
+ icon?: ReactNode
8
+ onReset?: () => void
9
+ resetTooltip?: {
10
+ title: string
11
+ description: string
12
+ }
13
+ confirmReset?: boolean
14
+ action?: ReactNode
15
+ variant?: 'default' | 'info' | 'warning' | 'danger'
16
+ }
17
+
18
+ const variantStyles = {
19
+ default: {
20
+ border: 'border-l-blue-500/60',
21
+ iconBg: 'bg-blue-500/10',
22
+ iconColor: 'text-blue-400',
23
+ },
24
+ info: {
25
+ border: 'border-l-cyan-500/60',
26
+ iconBg: 'bg-cyan-500/10',
27
+ iconColor: 'text-cyan-400',
28
+ },
29
+ warning: {
30
+ border: 'border-l-orange-500/60',
31
+ iconBg: 'bg-orange-500/10',
32
+ iconColor: 'text-orange-400',
33
+ },
34
+ danger: {
35
+ border: 'border-l-red-500/60',
36
+ iconBg: 'bg-red-500/10',
37
+ iconColor: 'text-red-400',
38
+ },
39
+ }
40
+
41
+ export function SettingsHeader({
42
+ description,
43
+ icon,
44
+ onReset,
45
+ resetTooltip,
46
+ action,
47
+ variant = 'default',
48
+ }: SettingsHeaderProps) {
49
+ const styles = variantStyles[variant]
50
+
51
+ return (
52
+ <div
53
+ className={`bg-neutral-950 border border-neutral-700 border-l-2 ${styles.border} rounded-lg p-4 flex items-center gap-4`}
54
+ >
55
+ <div className={`flex-shrink-0 w-8 h-8 rounded-lg ${styles.iconBg} flex items-center justify-center`}>
56
+ {icon ?? <Info className={`w-4 h-4 ${styles.iconColor}`} />}
57
+ </div>
58
+ <p className="flex-1 text-sm text-neutral-400 leading-relaxed">{description}</p>
59
+ <div className="flex-shrink-0 flex items-center gap-2">
60
+ {action}
61
+ {onReset && (
62
+ <IconButton
63
+ icon={<RotateCcw className="w-4 h-4" />}
64
+ onClick={onReset}
65
+ size="sm"
66
+ color="orange"
67
+ tooltip={
68
+ resetTooltip ?? {
69
+ title: 'Reset to Defaults',
70
+ description: 'Restore all settings to their default values',
71
+ }
72
+ }
73
+ />
74
+ )}
75
+ </div>
76
+ </div>
77
+ )
78
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react'
2
+ import { SettingsTreeNav } from './SettingsTreeNav.tsx'
3
+ import type { SettingsTreeNode } from './settings-tree-utils.ts'
4
+
5
+ export interface SettingsPanelProps {
6
+ tree: SettingsTreeNode[]
7
+ selectedPath: string
8
+ onSelectPath: (path: string) => void
9
+ children: ReactNode
10
+ }
11
+
12
+ export function SettingsPanel({ tree, selectedPath, onSelectPath, children }: SettingsPanelProps) {
13
+ return (
14
+ <div className="flex-1 flex overflow-hidden">
15
+ <SettingsTreeNav tree={tree} selectedPath={selectedPath} onSelectPath={onSelectPath} />
16
+ <div className="flex-1 overflow-y-auto">
17
+ {children}
18
+ </div>
19
+ </div>
20
+ )
21
+ }
@@ -0,0 +1,256 @@
1
+ import { useState, useEffect, useMemo } from 'react'
2
+ import { ChevronRight, ChevronDown, ChevronsUpDown, ChevronsDownUp } from 'lucide-react'
3
+ import { Input } from '../ui/input.tsx'
4
+ import { IconButton } from '../ui/icon-button.tsx'
5
+ import {
6
+ type SettingsTreeNode,
7
+ isLeafNode,
8
+ collectExpandablePaths,
9
+ getParentPaths,
10
+ filterTree,
11
+ } from './settings-tree-utils.ts'
12
+
13
+ export interface SettingsTreeNavProps {
14
+ tree: SettingsTreeNode[]
15
+ selectedPath: string
16
+ onSelectPath: (path: string) => void
17
+ }
18
+
19
+ interface TreeNodeProps {
20
+ node: SettingsTreeNode
21
+ path: string
22
+ selectedPath: string
23
+ onSelectPath: (path: string) => void
24
+ expandedPaths: Set<string>
25
+ onToggleExpand: (path: string) => void
26
+ depth: number
27
+ searchQuery?: string
28
+ }
29
+
30
+ function highlightMatch(text: string, query: string | undefined) {
31
+ if (!query) return text
32
+ const lowerText = text.toLowerCase()
33
+ const lowerQuery = query.toLowerCase()
34
+ const index = lowerText.indexOf(lowerQuery)
35
+ if (index === -1) return text
36
+ return (
37
+ <>
38
+ {text.slice(0, index)}
39
+ <span className="bg-yellow-500/30 text-yellow-200">
40
+ {text.slice(index, index + query.length)}
41
+ </span>
42
+ {text.slice(index + query.length)}
43
+ </>
44
+ )
45
+ }
46
+
47
+ function TreeNode({
48
+ node,
49
+ path,
50
+ selectedPath,
51
+ onSelectPath,
52
+ expandedPaths,
53
+ onToggleExpand,
54
+ depth,
55
+ searchQuery,
56
+ }: TreeNodeProps) {
57
+ const isLeaf = isLeafNode(node)
58
+ const isExpanded = expandedPaths.has(path)
59
+ const isSelected = selectedPath === path
60
+ const isInSelectedPath = selectedPath.startsWith(path + '.')
61
+
62
+ const handleClick = () => {
63
+ if (isLeaf) {
64
+ onSelectPath(path)
65
+ } else {
66
+ onToggleExpand(path)
67
+ }
68
+ }
69
+
70
+ return (
71
+ <div>
72
+ <button
73
+ onClick={handleClick}
74
+ className={`
75
+ w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer
76
+ ${isSelected ? 'bg-blue-500/20 text-blue-400' : isInSelectedPath ? 'text-neutral-300' : 'text-neutral-400'}
77
+ ${!isSelected && 'hover:bg-neutral-800 hover:text-neutral-200'}
78
+ `}
79
+ style={{ paddingLeft: `${depth * 12 + 8}px` }}
80
+ >
81
+ {!isLeaf && (
82
+ <span className="w-4 h-4 flex items-center justify-center flex-shrink-0">
83
+ {isExpanded ? (
84
+ <ChevronDown className="w-3.5 h-3.5" />
85
+ ) : (
86
+ <ChevronRight className="w-3.5 h-3.5" />
87
+ )}
88
+ </span>
89
+ )}
90
+
91
+ {isLeaf && <span className="w-4 h-4 flex-shrink-0" />}
92
+
93
+ <span className="flex-shrink-0">{node.icon}</span>
94
+
95
+ <span className="truncate">
96
+ {searchQuery ? highlightMatch(node.label, searchQuery) : node.label}
97
+ </span>
98
+ </button>
99
+
100
+ {!isLeaf && isExpanded && node.children && (
101
+ <div>
102
+ {node.children.map((child) => (
103
+ <TreeNode
104
+ key={child.id}
105
+ node={child}
106
+ path={`${path}.${child.id}`}
107
+ selectedPath={selectedPath}
108
+ onSelectPath={onSelectPath}
109
+ expandedPaths={expandedPaths}
110
+ onToggleExpand={onToggleExpand}
111
+ depth={depth + 1}
112
+ searchQuery={searchQuery}
113
+ />
114
+ ))}
115
+ </div>
116
+ )}
117
+ </div>
118
+ )
119
+ }
120
+
121
+ export function SettingsTreeNav({ tree, selectedPath, onSelectPath }: SettingsTreeNavProps) {
122
+ const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
123
+ const expanded = new Set<string>()
124
+ if (selectedPath) {
125
+ for (const p of getParentPaths(selectedPath)) {
126
+ expanded.add(p)
127
+ }
128
+ }
129
+ return expanded
130
+ })
131
+
132
+ const [searchQuery, setSearchQuery] = useState('')
133
+
134
+ const allExpandablePaths = useMemo(() => collectExpandablePaths(tree), [tree])
135
+
136
+ const filteredTree = useMemo(
137
+ () => filterTree(tree, searchQuery),
138
+ [tree, searchQuery],
139
+ )
140
+
141
+ const collapsedCount = allExpandablePaths.filter((p) => !expandedPaths.has(p)).length
142
+ const allCollapsed = collapsedCount === allExpandablePaths.length && allExpandablePaths.length > 0
143
+ const allExpanded = collapsedCount === 0
144
+
145
+ useEffect(() => {
146
+ if (selectedPath) {
147
+ const parents = getParentPaths(selectedPath)
148
+ const newExpanded = new Set(expandedPaths)
149
+ let changed = false
150
+
151
+ for (const p of parents) {
152
+ if (!newExpanded.has(p)) {
153
+ newExpanded.add(p)
154
+ changed = true
155
+ }
156
+ }
157
+
158
+ if (changed) {
159
+ setExpandedPaths(newExpanded)
160
+ }
161
+ }
162
+ }, [selectedPath])
163
+
164
+ useEffect(() => {
165
+ if (searchQuery.trim()) {
166
+ setExpandedPaths(new Set(allExpandablePaths))
167
+ }
168
+ }, [searchQuery, allExpandablePaths])
169
+
170
+ const handleToggleExpand = (path: string) => {
171
+ setExpandedPaths((prev) => {
172
+ const next = new Set(prev)
173
+ if (next.has(path)) {
174
+ next.delete(path)
175
+ } else {
176
+ next.add(path)
177
+ }
178
+ return next
179
+ })
180
+ }
181
+
182
+ const handleExpandAll = () => {
183
+ setExpandedPaths(new Set(allExpandablePaths))
184
+ }
185
+
186
+ const handleCollapseAll = () => {
187
+ const parentPaths = getParentPaths(selectedPath)
188
+ setExpandedPaths(new Set(parentPaths))
189
+ }
190
+
191
+ return (
192
+ <nav className="w-56 flex-shrink-0 bg-neutral-900 border-r border-neutral-800 flex flex-col overflow-hidden">
193
+ <div className="p-2 border-b border-neutral-800">
194
+ <div className="flex items-center gap-1">
195
+ <Input
196
+ type="search"
197
+ placeholder="Search..."
198
+ value={searchQuery}
199
+ onChange={setSearchQuery}
200
+ size="sm"
201
+ variant="filled"
202
+ />
203
+
204
+ {!allExpanded && allExpandablePaths.length > 0 && (
205
+ <IconButton
206
+ icon={<ChevronsUpDown className="w-3.5 h-3.5" />}
207
+ onClick={handleExpandAll}
208
+ size="sm"
209
+ color="neutral"
210
+ tooltipPosition="left"
211
+ tooltip={{
212
+ title: 'Expand All',
213
+ description: 'Expand all sections',
214
+ }}
215
+ />
216
+ )}
217
+ {!allCollapsed && allExpandablePaths.length > 0 && (
218
+ <IconButton
219
+ icon={<ChevronsDownUp className="w-3.5 h-3.5" />}
220
+ onClick={handleCollapseAll}
221
+ size="sm"
222
+ color="neutral"
223
+ tooltipPosition="left"
224
+ tooltip={{
225
+ title: 'Collapse All',
226
+ description: 'Collapse all sections',
227
+ }}
228
+ />
229
+ )}
230
+ </div>
231
+ </div>
232
+
233
+ <div className="flex-1 overflow-y-auto py-2">
234
+ {filteredTree.length === 0 && searchQuery ? (
235
+ <p className="text-xs text-neutral-500 text-center py-4 px-2">
236
+ No settings match &quot;{searchQuery}&quot;
237
+ </p>
238
+ ) : (
239
+ filteredTree.map((node) => (
240
+ <TreeNode
241
+ key={node.id}
242
+ node={node}
243
+ path={node.id}
244
+ selectedPath={selectedPath}
245
+ onSelectPath={onSelectPath}
246
+ expandedPaths={expandedPaths}
247
+ onToggleExpand={handleToggleExpand}
248
+ depth={0}
249
+ searchQuery={searchQuery}
250
+ />
251
+ ))
252
+ )}
253
+ </div>
254
+ </nav>
255
+ )
256
+ }
@@ -0,0 +1,7 @@
1
+ export { SettingsPanel } from './SettingsPanel.tsx'
2
+ export type { SettingsPanelProps } from './SettingsPanel.tsx'
3
+ export { SettingsTreeNav } from './SettingsTreeNav.tsx'
4
+ export type { SettingsTreeNavProps } from './SettingsTreeNav.tsx'
5
+ export { SettingsHeader } from './SettingsHeader.tsx'
6
+ export type { SettingsHeaderProps } from './SettingsHeader.tsx'
7
+ export * from './settings-tree-utils.ts'