@toolr/ui-design 0.1.0

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 (112) hide show
  1. package/README.md +63 -0
  2. package/components/content/info-panel-primitives.tsx +297 -0
  3. package/components/diagrams/diagram-utils.tsx +908 -0
  4. package/components/hooks/use-click-outside.ts +27 -0
  5. package/components/hooks/use-dropdown-max-height.ts +20 -0
  6. package/components/hooks/use-navigation-history.ts +94 -0
  7. package/components/lib/ai-tools.tsx +44 -0
  8. package/components/lib/cn.ts +6 -0
  9. package/components/lib/form-colors.ts +32 -0
  10. package/components/lib/theme-engine.ts +97 -0
  11. package/components/lib/toolr-brand.tsx +31 -0
  12. package/components/sections/ai-tools-paths/index.ts +37 -0
  13. package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
  14. package/components/sections/ai-tools-paths/types.ts +111 -0
  15. package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
  16. package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
  17. package/components/sections/captured-issues/index.ts +38 -0
  18. package/components/sections/captured-issues/types.ts +113 -0
  19. package/components/sections/captured-issues/use-captured-issues.ts +111 -0
  20. package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
  21. package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
  22. package/components/sections/golden-snapshots/index.ts +145 -0
  23. package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
  24. package/components/sections/golden-snapshots/status-overview.tsx +305 -0
  25. package/components/sections/golden-snapshots/types.ts +288 -0
  26. package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
  27. package/components/sections/golden-snapshots/version-manager.tsx +186 -0
  28. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
  29. package/components/sections/prompt-editor/index.ts +121 -0
  30. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
  31. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
  32. package/components/sections/prompt-editor/types.ts +101 -0
  33. package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
  34. package/components/sections/report-bug/error-logger.ts +392 -0
  35. package/components/sections/report-bug/index.ts +59 -0
  36. package/components/sections/report-bug/issue-reporter-api.ts +83 -0
  37. package/components/sections/report-bug/report-bug-form.tsx +282 -0
  38. package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
  39. package/components/sections/report-bug/use-report-bug.ts +170 -0
  40. package/components/sections/snapshot-browser/index.ts +53 -0
  41. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
  42. package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
  43. package/components/sections/snapshot-browser/types.ts +106 -0
  44. package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
  45. package/components/sections/snippets-editor/index.ts +31 -0
  46. package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
  47. package/components/sections/snippets-editor/types.ts +48 -0
  48. package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
  49. package/components/ui/action-dialog.tsx +309 -0
  50. package/components/ui/ai-action-button.tsx +137 -0
  51. package/components/ui/ai-execution-action-buttons.tsx +106 -0
  52. package/components/ui/badge.tsx +67 -0
  53. package/components/ui/bottom-panel-header.tsx +240 -0
  54. package/components/ui/breadcrumb.tsx +168 -0
  55. package/components/ui/checkbox.tsx +102 -0
  56. package/components/ui/collapsible-section.tsx +100 -0
  57. package/components/ui/confirm-badge.tsx +71 -0
  58. package/components/ui/detail-section.tsx +67 -0
  59. package/components/ui/detail-view-wrapper.tsx +55 -0
  60. package/components/ui/editor-placeholder-card.tsx +197 -0
  61. package/components/ui/editor-toolbar.tsx +123 -0
  62. package/components/ui/execution-details-panel.tsx +93 -0
  63. package/components/ui/extension-list-card.tsx +105 -0
  64. package/components/ui/file-structure-section.tsx +373 -0
  65. package/components/ui/file-tree.tsx +171 -0
  66. package/components/ui/files-panel.tsx +251 -0
  67. package/components/ui/filter-dropdown.tsx +173 -0
  68. package/components/ui/form-actions.tsx +127 -0
  69. package/components/ui/frontmatter-form-header.tsx +80 -0
  70. package/components/ui/icon-button.tsx +388 -0
  71. package/components/ui/input.tsx +211 -0
  72. package/components/ui/label.tsx +159 -0
  73. package/components/ui/layout-tab-bar.tsx +289 -0
  74. package/components/ui/modal.tsx +194 -0
  75. package/components/ui/nav-card.tsx +81 -0
  76. package/components/ui/navigation-bar.tsx +285 -0
  77. package/components/ui/number-input.tsx +165 -0
  78. package/components/ui/registry-browser.tsx +261 -0
  79. package/components/ui/registry-card.tsx +710 -0
  80. package/components/ui/registry-detail.tsx +224 -0
  81. package/components/ui/resizable-textarea.tsx +290 -0
  82. package/components/ui/scope-badge.tsx +67 -0
  83. package/components/ui/segmented-toggle.tsx +133 -0
  84. package/components/ui/select.tsx +172 -0
  85. package/components/ui/selection-grid.tsx +313 -0
  86. package/components/ui/setting-row.tsx +97 -0
  87. package/components/ui/snapshot-card.tsx +107 -0
  88. package/components/ui/snippets-panel.tsx +161 -0
  89. package/components/ui/sort-dropdown.tsx +109 -0
  90. package/components/ui/status-card.tsx +96 -0
  91. package/components/ui/tab-bar.tsx +340 -0
  92. package/components/ui/toggle.tsx +142 -0
  93. package/components/ui/tooltip.tsx +326 -0
  94. package/dist/content.d.ts +110 -0
  95. package/dist/content.js +195 -0
  96. package/dist/diagrams.d.ts +371 -0
  97. package/dist/diagrams.js +702 -0
  98. package/dist/index.d.ts +2714 -0
  99. package/dist/index.js +11220 -0
  100. package/dist/preset.d.ts +24 -0
  101. package/dist/preset.js +17 -0
  102. package/dist/tokens/tokens/primitives.css +45 -0
  103. package/dist/tokens/tokens/semantic.css +46 -0
  104. package/dist/tokens/tokens/theme.css +11 -0
  105. package/dist/tokens/tokens/tokens.json +65 -0
  106. package/index.ts +123 -0
  107. package/package.json +63 -0
  108. package/tailwind-preset.ts +22 -0
  109. package/tokens/primitives.css +45 -0
  110. package/tokens/semantic.css +46 -0
  111. package/tokens/theme.css +11 -0
  112. package/tokens/tokens.json +65 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * SettingRow - A settings row with built-in control types
3
+ *
4
+ * Used by:
5
+ * - Settings pages - boolean toggles, dropdowns, text inputs
6
+ * - Configuration panels - key-value setting rows
7
+ *
8
+ * Control types:
9
+ * - toggle: renders a Toggle switch
10
+ * - select: renders a Select dropdown
11
+ * - input: renders a text Input
12
+ */
13
+
14
+ import { Toggle, type ToggleColor, type ToggleSize, type ToggleVariant } from './toggle.tsx'
15
+ import { Select, type SelectOption } from './select.tsx'
16
+ import { Input } from './input.tsx'
17
+
18
+ interface SettingRowBase {
19
+ label: string
20
+ description?: string
21
+ disabled?: boolean
22
+ className?: string
23
+ }
24
+
25
+ interface SettingRowToggle extends SettingRowBase {
26
+ type: 'toggle'
27
+ checked: boolean
28
+ onChange: (checked: boolean) => void
29
+ color?: ToggleColor
30
+ size?: ToggleSize
31
+ variant?: ToggleVariant
32
+ }
33
+
34
+ interface SettingRowSelect extends SettingRowBase {
35
+ type: 'select'
36
+ value: string
37
+ options: SelectOption[]
38
+ onChange: (value: string) => void
39
+ selectSize?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
40
+ selectVariant?: 'filled' | 'outline'
41
+ }
42
+
43
+ interface SettingRowInput extends SettingRowBase {
44
+ type: 'input'
45
+ value: string
46
+ onChange: (value: string) => void
47
+ placeholder?: string
48
+ inputSize?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
49
+ inputVariant?: 'filled' | 'outline'
50
+ }
51
+
52
+ export type SettingRowProps = SettingRowToggle | SettingRowSelect | SettingRowInput
53
+
54
+ export function SettingRow(props: SettingRowProps) {
55
+ const { label, description, disabled, className = '' } = props
56
+
57
+ return (
58
+ <div className={`flex items-start justify-between gap-4 ${className}`}>
59
+ <div>
60
+ <label className="text-neutral-200 leading-7">{label}</label>
61
+ {description && <p className="text-sm text-neutral-500">{description}</p>}
62
+ </div>
63
+ {props.type === 'toggle' && (
64
+ <Toggle
65
+ checked={props.checked}
66
+ onChange={props.onChange}
67
+ disabled={disabled}
68
+ color={props.color}
69
+ size={props.size}
70
+ variant={props.variant}
71
+ />
72
+ )}
73
+ {props.type === 'select' && (
74
+ <Select
75
+ value={props.value}
76
+ options={props.options}
77
+ onChange={props.onChange}
78
+ disabled={disabled}
79
+ size={props.selectSize ?? 'sm'}
80
+ variant={props.selectVariant ?? 'outline'}
81
+ />
82
+ )}
83
+ {props.type === 'input' && (
84
+ <div className="w-44 shrink-0">
85
+ <Input
86
+ value={props.value}
87
+ onChange={props.onChange}
88
+ disabled={disabled}
89
+ placeholder={props.placeholder}
90
+ size={props.inputSize ?? 'sm'}
91
+ variant={props.inputVariant ?? 'outline'}
92
+ />
93
+ </div>
94
+ )}
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,107 @@
1
+ /** Snapshot card with status stripe, stats grid, and sync/view actions. */
2
+
3
+ import { RefreshCw, Eye } from 'lucide-react'
4
+ import { cn } from '../lib/cn.ts'
5
+ import { Label, type LabelColor } from './label.tsx'
6
+ import type { IconName } from './icon-button.tsx'
7
+
8
+ type SnapshotStatus = 'synced' | 'pending' | 'conflict' | 'outdated'
9
+
10
+ export interface SnapshotCardProps {
11
+ title: string
12
+ timestamp?: string
13
+ status: SnapshotStatus
14
+ description?: string
15
+ stats?: { label: string; value: string | number }[]
16
+ onSync?: () => void
17
+ onView?: () => void
18
+ className?: string
19
+ }
20
+
21
+ const statusStripeColor: Record<SnapshotStatus, string> = {
22
+ synced: 'bg-green-400',
23
+ pending: 'bg-amber-400',
24
+ conflict: 'bg-red-400',
25
+ outdated: 'bg-neutral-500',
26
+ }
27
+
28
+ const statusLabelConfig: Record<SnapshotStatus, { text: string; color: LabelColor; icon: IconName; tooltip: string }> = {
29
+ synced: { text: 'Synced', color: 'green', icon: 'check-circle', tooltip: 'Snapshot is in sync' },
30
+ pending: { text: 'Pending', color: 'amber', icon: 'loader', tooltip: 'Snapshot is awaiting sync' },
31
+ conflict: { text: 'Conflict', color: 'red', icon: 'alert-triangle', tooltip: 'Snapshot has conflicts' },
32
+ outdated: { text: 'Outdated', color: 'neutral', icon: 'info', tooltip: 'Snapshot is outdated' },
33
+ }
34
+
35
+ export function SnapshotCard({
36
+ title,
37
+ timestamp,
38
+ status,
39
+ description,
40
+ stats,
41
+ onSync,
42
+ onView,
43
+ className,
44
+ }: SnapshotCardProps) {
45
+ return (
46
+ <div className={cn('rounded-lg border border-neutral-700 bg-neutral-800 overflow-hidden', className)}>
47
+ <div className={cn('h-1', statusStripeColor[status])} />
48
+
49
+ <div className="p-4">
50
+ <div className="flex items-start justify-between gap-2">
51
+ <h3 className="text-sm font-medium text-neutral-200 truncate">{title}</h3>
52
+ <Label
53
+ text={statusLabelConfig[status].text}
54
+ color={statusLabelConfig[status].color}
55
+ icon={statusLabelConfig[status].icon}
56
+ tooltip={{ description: statusLabelConfig[status].tooltip }}
57
+ size="xs"
58
+ />
59
+ </div>
60
+
61
+ {timestamp && (
62
+ <p className="mt-1 text-[10px] text-neutral-500">{timestamp}</p>
63
+ )}
64
+
65
+ {description && (
66
+ <p className="mt-2 text-xs text-neutral-500 leading-relaxed line-clamp-2">{description}</p>
67
+ )}
68
+
69
+ {stats && stats.length > 0 && (
70
+ <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">
71
+ {stats.map((stat) => (
72
+ <div key={stat.label}>
73
+ <p className="text-[10px] text-neutral-500">{stat.label}</p>
74
+ <p className="text-xs font-medium text-neutral-200">{stat.value}</p>
75
+ </div>
76
+ ))}
77
+ </div>
78
+ )}
79
+
80
+ {(onSync || onView) && (
81
+ <div className="mt-3 pt-3 border-t border-neutral-700 flex items-center gap-2">
82
+ {onSync && (
83
+ <button
84
+ type="button"
85
+ onClick={onSync}
86
+ className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md bg-blue-400/15 text-blue-400 hover:bg-blue-400/25 transition-colors cursor-pointer"
87
+ >
88
+ <RefreshCw className={cn('w-3 h-3', status === 'pending' && 'animate-spin')} />
89
+ Sync
90
+ </button>
91
+ )}
92
+ {onView && (
93
+ <button
94
+ type="button"
95
+ onClick={onView}
96
+ className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md text-neutral-400 hover:bg-neutral-700 transition-colors cursor-pointer"
97
+ >
98
+ <Eye className="w-3 h-3" />
99
+ View
100
+ </button>
101
+ )}
102
+ </div>
103
+ )}
104
+ </div>
105
+ </div>
106
+ )
107
+ }
@@ -0,0 +1,161 @@
1
+ /** Searchable panel for browsing, copying, and inserting code snippets. */
2
+
3
+ import { useState, useMemo } from 'react'
4
+ import { Search } from 'lucide-react'
5
+ import { cn } from '../lib/cn.ts'
6
+ import { IconButton } from './icon-button.tsx'
7
+
8
+ export interface Snippet {
9
+ id: string
10
+ label: string
11
+ language?: string
12
+ description?: string
13
+ code: string
14
+ tags?: string[]
15
+ }
16
+
17
+ export interface SnippetsPanelProps {
18
+ snippets: Snippet[]
19
+ onInsert?: (id: string) => void
20
+ onCopy?: (id: string) => void
21
+ showSearch?: boolean
22
+ className?: string
23
+ }
24
+
25
+ const LANGUAGE_COLORS: Record<string, string> = {
26
+ typescript: '#3178c6',
27
+ javascript: '#f7df1e',
28
+ tsx: '#3178c6',
29
+ jsx: '#f7df1e',
30
+ css: '#264de4',
31
+ html: '#e34c26',
32
+ json: '#f9e2af',
33
+ python: '#3572a5',
34
+ rust: '#dea584',
35
+ go: '#00add8',
36
+ bash: 'blue-400',
37
+ shell: 'blue-400',
38
+ sql: '#e38c00',
39
+ }
40
+
41
+ function matchesSearch(snippet: Snippet, query: string): boolean {
42
+ if (snippet.label.toLowerCase().includes(query)) return true
43
+ if (snippet.description?.toLowerCase().includes(query)) return true
44
+ if (snippet.language?.toLowerCase().includes(query)) return true
45
+ if (snippet.tags?.some((t) => t.toLowerCase().includes(query))) return true
46
+ return false
47
+ }
48
+
49
+ export function SnippetsPanel({
50
+ snippets,
51
+ onInsert,
52
+ onCopy,
53
+ showSearch = false,
54
+ className,
55
+ }: SnippetsPanelProps) {
56
+ const [searchQuery, setSearchQuery] = useState('')
57
+
58
+ const filteredSnippets = useMemo(
59
+ () => searchQuery ? snippets.filter((s) => matchesSearch(s, searchQuery.toLowerCase())) : snippets,
60
+ [snippets, searchQuery],
61
+ )
62
+
63
+ return (
64
+ <div className={cn('flex flex-col bg-neutral-800 rounded-lg overflow-hidden', className)}>
65
+ <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
66
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Snippets</span>
67
+ <span className="text-[10px] text-neutral-500">{snippets.length} snippets</span>
68
+ </div>
69
+ {showSearch && (
70
+ <div className="px-2 py-2 border-b border-neutral-700">
71
+ <div className="flex items-center gap-1.5 px-2 py-1 bg-black border border-neutral-700 rounded text-xs">
72
+ <Search className="w-3 h-3 text-neutral-500 shrink-0" />
73
+ <input
74
+ type="text"
75
+ placeholder="Search snippets..."
76
+ value={searchQuery}
77
+ onChange={(e) => setSearchQuery(e.target.value)}
78
+ className="flex-1 bg-transparent text-neutral-200 placeholder-neutral-500 outline-none text-xs"
79
+ />
80
+ </div>
81
+ </div>
82
+ )}
83
+ <div className="flex-1 overflow-y-auto p-2 space-y-2">
84
+ {filteredSnippets.map((snippet: Snippet) => (
85
+ <SnippetCard
86
+ key={snippet.id}
87
+ snippet={snippet}
88
+ onInsert={onInsert}
89
+ onCopy={onCopy}
90
+ />
91
+ ))}
92
+ {filteredSnippets.length === 0 && (
93
+ <p className="text-[11px] text-neutral-500 text-center py-4">No snippets found</p>
94
+ )}
95
+ </div>
96
+ </div>
97
+ )
98
+ }
99
+
100
+ interface SnippetCardProps {
101
+ snippet: Snippet
102
+ onInsert?: (id: string) => void
103
+ onCopy?: (id: string) => void
104
+ }
105
+
106
+ function SnippetCard({ snippet, onInsert, onCopy }: SnippetCardProps) {
107
+ const langColor = snippet.language ? LANGUAGE_COLORS[snippet.language.toLowerCase()] ?? '#6b7280' : '#6b7280'
108
+
109
+ return (
110
+ <div className="rounded-md border border-neutral-700 bg-neutral-900 hover:border-neutral-600 transition-colors">
111
+ <div className="flex items-center justify-between gap-2 px-2.5 py-1.5">
112
+ <div className="flex items-center gap-2 min-w-0">
113
+ <span className="text-xs font-medium text-neutral-200 truncate">{snippet.label}</span>
114
+ {snippet.language && (
115
+ <span
116
+ className="px-1.5 py-0.5 text-[9px] font-medium rounded shrink-0"
117
+ style={{ color: langColor, backgroundColor: `${langColor}20` }}
118
+ >
119
+ {snippet.language}
120
+ </span>
121
+ )}
122
+ </div>
123
+ <div className="flex items-center gap-0.5 shrink-0">
124
+ {onInsert && (
125
+ <IconButton
126
+ icon="arrow-down-to-line"
127
+ onClick={() => onInsert(snippet.id)}
128
+ size="xss"
129
+ tooltip={{ title: 'Insert snippet', description: 'Insert this snippet' }}
130
+ />
131
+ )}
132
+ {onCopy && (
133
+ <IconButton
134
+ icon="copy"
135
+ onClick={() => onCopy(snippet.id)}
136
+ size="xss"
137
+ tooltip={{ title: 'Copy snippet', description: 'Copy to clipboard' }}
138
+ />
139
+ )}
140
+ </div>
141
+ </div>
142
+ {snippet.description && (
143
+ <p className="px-2.5 pb-1.5 text-[11px] text-neutral-400 leading-relaxed">{snippet.description}</p>
144
+ )}
145
+ <div className="mx-2.5 mb-2 rounded bg-black border border-neutral-700 overflow-hidden">
146
+ <pre className="p-2 text-[11px] text-neutral-400 leading-relaxed overflow-x-auto max-h-24">
147
+ <code>{snippet.code}</code>
148
+ </pre>
149
+ </div>
150
+ {snippet.tags && snippet.tags.length > 0 && (
151
+ <div className="flex flex-wrap gap-1 px-2.5 pb-2">
152
+ {snippet.tags.map((tag) => (
153
+ <span key={tag} className="px-1.5 py-0.5 text-[9px] rounded bg-neutral-700 text-neutral-500">
154
+ {tag}
155
+ </span>
156
+ ))}
157
+ </div>
158
+ )}
159
+ </div>
160
+ )
161
+ }
@@ -0,0 +1,109 @@
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { ArrowUp, ArrowDown, ChevronDown, Check } from 'lucide-react'
3
+ import { useClickOutside } from '../hooks/use-click-outside.ts'
4
+ import { useDropdownMaxHeight } from '../hooks/use-dropdown-max-height.ts'
5
+ import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
6
+
7
+ const VARIANT_CLASSES = {
8
+ filled: { bg: 'bg-neutral-800', hoverBg: 'hover:bg-neutral-700' },
9
+ outline: { bg: 'bg-transparent', hoverBg: 'hover:bg-neutral-800' },
10
+ }
11
+
12
+ export interface SortField {
13
+ value: string
14
+ label: string
15
+ /** Label suffix when ascending (default: "↑") */
16
+ ascLabel?: string
17
+ /** Label suffix when descending (default: "↓") */
18
+ descLabel?: string
19
+ }
20
+
21
+ export interface SortDropdownProps {
22
+ field: string
23
+ ascending: boolean
24
+ onFieldChange: (field: string) => void
25
+ onToggleDirection: () => void
26
+ fields: SortField[]
27
+ variant?: 'filled' | 'outline'
28
+ color?: FormColor
29
+ }
30
+
31
+ export function SortDropdown({
32
+ field,
33
+ ascending,
34
+ onFieldChange,
35
+ onToggleDirection,
36
+ fields,
37
+ variant = 'outline',
38
+ color = 'blue',
39
+ }: SortDropdownProps) {
40
+ const [isOpen, setIsOpen] = useState(false)
41
+ const [highlightIdx, setHighlightIdx] = useState(-1)
42
+ const ref = useRef<HTMLDivElement>(null)
43
+ const menuRef = useDropdownMaxHeight<HTMLDivElement>(isOpen)
44
+ const v = VARIANT_CLASSES[variant]
45
+
46
+ useClickOutside(ref, isOpen, () => setIsOpen(false))
47
+
48
+ useEffect(() => {
49
+ if (isOpen) setHighlightIdx(fields.findIndex((f) => f.value === field))
50
+ }, [isOpen, fields, field])
51
+
52
+ const handleKeyDown = (e: React.KeyboardEvent) => {
53
+ if (!isOpen) return
54
+ if (e.key === 'ArrowDown') {
55
+ e.preventDefault()
56
+ setHighlightIdx((i) => Math.min(i + 1, fields.length - 1))
57
+ } else if (e.key === 'ArrowUp') {
58
+ e.preventDefault()
59
+ setHighlightIdx((i) => Math.max(i - 1, 0))
60
+ } else if (e.key === 'Enter' && highlightIdx >= 0) {
61
+ e.preventDefault()
62
+ onFieldChange(fields[highlightIdx].value)
63
+ setIsOpen(false)
64
+ } else if (e.key === 'Escape') {
65
+ e.preventDefault()
66
+ setIsOpen(false)
67
+ }
68
+ }
69
+
70
+ const current = fields.find((f) => f.value === field) ?? fields[0]
71
+ const DirIcon = ascending ? ArrowUp : ArrowDown
72
+
73
+ return (
74
+ <div className="relative flex items-center" ref={ref} onKeyDown={handleKeyDown}>
75
+ <button
76
+ onClick={() => setIsOpen(!isOpen)}
77
+ className={`flex items-center gap-1.5 h-7 px-2 rounded-md border ${v.bg} text-xs transition-colors cursor-pointer ${FORM_COLORS[color].border} text-neutral-200 ${FORM_COLORS[color].hover}`}
78
+ >
79
+ <span
80
+ className={`${FORM_COLORS[color].accent} hover:brightness-125 transition-colors`}
81
+ onClick={(e) => { e.stopPropagation(); onToggleDirection() }}
82
+ role="button"
83
+ >
84
+ <DirIcon className="w-3 h-3" />
85
+ </span>
86
+ <span className="whitespace-nowrap">{current.label}</span>
87
+ <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
88
+ </button>
89
+
90
+ {isOpen && (
91
+ <div ref={menuRef} className={`absolute right-0 top-full z-50 mt-1 min-w-[140px] ${v.bg} border ${FORM_COLORS[color].border} rounded-lg shadow-xl overflow-hidden`}>
92
+ {fields.map((f, idx) => (
93
+ <button
94
+ key={f.value}
95
+ onClick={() => { onFieldChange(f.value); setIsOpen(false) }}
96
+ onPointerEnter={() => setHighlightIdx(idx)}
97
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer ${
98
+ idx === highlightIdx ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : field === f.value ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
99
+ }`}
100
+ >
101
+ <Check className={`w-3 h-3 shrink-0 ${field === f.value ? FORM_COLORS[color].accent : 'invisible'}`} />
102
+ <span>{f.label}</span>
103
+ </button>
104
+ ))}
105
+ </div>
106
+ )}
107
+ </div>
108
+ )
109
+ }
@@ -0,0 +1,96 @@
1
+ /** Status card displaying a list of labeled items with color-coded status indicators. */
2
+
3
+ import {
4
+ Settings, Shield, Database, Globe, Zap, Code, Terminal,
5
+ Star, Cloud, Bell, Heart, Sparkles, Bot, Plug,
6
+ } from 'lucide-react'
7
+ import type { LucideIcon } from 'lucide-react'
8
+ import type { IconName } from './icon-button.tsx'
9
+ import { cn } from '../lib/cn.ts'
10
+
11
+ const iconSubset: Partial<Record<IconName, LucideIcon>> = {
12
+ settings: Settings, shield: Shield, database: Database, globe: Globe,
13
+ zap: Zap, code: Code, terminal: Terminal, star: Star, cloud: Cloud,
14
+ bell: Bell, heart: Heart, sparkles: Sparkles, bot: Bot, plug: Plug,
15
+ }
16
+
17
+ type StatusType = 'success' | 'warning' | 'error' | 'info' | 'neutral'
18
+
19
+ export interface StatusItem {
20
+ label: string
21
+ value: string | number
22
+ status: StatusType
23
+ }
24
+
25
+ export interface StatusCardProps {
26
+ title: string
27
+ icon?: IconName
28
+ iconColor?: string
29
+ items: StatusItem[]
30
+ action?: { label: string; onClick: () => void }
31
+ className?: string
32
+ }
33
+
34
+ const statusDotColor: Record<StatusType, string> = {
35
+ success: 'bg-green-400',
36
+ warning: 'bg-amber-400',
37
+ error: 'bg-red-400',
38
+ info: 'bg-blue-400',
39
+ neutral: 'bg-neutral-500',
40
+ }
41
+
42
+ const statusValueColor: Record<StatusType, string> = {
43
+ success: 'text-green-400',
44
+ warning: 'text-amber-400',
45
+ error: 'text-red-400',
46
+ info: 'text-blue-400',
47
+ neutral: 'text-neutral-400',
48
+ }
49
+
50
+ export function StatusCard({
51
+ title,
52
+ icon,
53
+ iconColor = 'blue-400',
54
+ items,
55
+ action,
56
+ className,
57
+ }: StatusCardProps) {
58
+ const Icon = icon ? iconSubset[icon] : undefined
59
+
60
+ return (
61
+ <div className={cn('rounded-lg border border-neutral-700 bg-neutral-800 overflow-hidden', className)}>
62
+ <div className="flex items-center gap-2.5 px-4 py-3 border-b border-neutral-700">
63
+ {Icon && (
64
+ <Icon className="w-4 h-4 shrink-0" style={{ color: iconColor }} />
65
+ )}
66
+ <h3 className="text-sm font-medium text-neutral-200">{title}</h3>
67
+ </div>
68
+
69
+ <div className="divide-y divide-[#374151]/60">
70
+ {items.map((item) => (
71
+ <div key={item.label} className="flex items-center justify-between px-4 py-2.5">
72
+ <span className="text-xs text-neutral-400">{item.label}</span>
73
+ <div className="flex items-center gap-2">
74
+ <span className={cn('text-xs font-medium', statusValueColor[item.status])}>
75
+ {item.value}
76
+ </span>
77
+ <span className={cn('w-2 h-2 rounded-full shrink-0', statusDotColor[item.status])} />
78
+ </div>
79
+ </div>
80
+ ))}
81
+ </div>
82
+
83
+ {action && (
84
+ <div className="px-4 py-2.5 border-t border-neutral-700">
85
+ <button
86
+ type="button"
87
+ onClick={action.onClick}
88
+ className="text-xs text-blue-400 hover:text-blue-300 transition-colors cursor-pointer"
89
+ >
90
+ {action.label}
91
+ </button>
92
+ </div>
93
+ )}
94
+ </div>
95
+ )
96
+ }