@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,211 @@
1
+ /**
2
+ * Input - Controlled text input with error state support
3
+ *
4
+ * Used by:
5
+ * - Forms - text fields, URLs, names
6
+ * - Search bars - filter inputs
7
+ * - Dialogs - single-line text entry
8
+ *
9
+ * Features:
10
+ * - Visual variants (filled, outline)
11
+ * - Size variants (sm, md)
12
+ * - Error state with optional message
13
+ * - Action slot inside the field (hover/focus reveal)
14
+ * - Search type with built-in icon, clear button, and auto-attributes
15
+ * - Password type with built-in reveal/hide toggle button
16
+ * - Optional debounce with visual SVG animation
17
+ * - Extends native input attributes
18
+ */
19
+
20
+ import { forwardRef, useEffect, useRef, useState, type InputHTMLAttributes, type ReactNode } from 'react'
21
+ import { Search, X, Eye, EyeOff } from 'lucide-react'
22
+ import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
23
+
24
+ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'type'> {
25
+ value: string
26
+ onChange: (value: string) => void
27
+ type?: 'text' | 'search' | 'password'
28
+ debounceMs?: number
29
+ error?: boolean | string
30
+ variant?: 'filled' | 'outline'
31
+ color?: FormColor
32
+ size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
33
+ mono?: boolean
34
+ /** Test ID for E2E testing */
35
+ testId?: string
36
+ /** Element rendered inside the input (right side), visible on hover/focus. Use IconButton size="xss". */
37
+ actionSlot?: ReactNode
38
+ }
39
+
40
+ const sizeClasses = {
41
+ xss: 'px-1 py-0.5 text-[10px]',
42
+ xs: 'px-1.5 py-0.5 text-xs',
43
+ sm: 'px-2 py-1 text-xs',
44
+ md: 'px-3 py-1.5 text-sm',
45
+ lg: 'px-3 py-2 text-sm',
46
+ }
47
+
48
+ const variantClasses = {
49
+ filled: 'bg-neutral-800',
50
+ outline: 'bg-transparent',
51
+ }
52
+
53
+ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
54
+ value,
55
+ onChange,
56
+ type = 'text',
57
+ debounceMs = 0,
58
+ error,
59
+ variant = 'outline',
60
+ color = 'blue',
61
+ size = 'sm',
62
+ disabled = false,
63
+ className = '',
64
+ mono = false,
65
+ testId,
66
+ actionSlot,
67
+ onFocus: onFocusProp,
68
+ onBlur: onBlurProp,
69
+ ...props
70
+ }, ref) {
71
+ const hasError = Boolean(error)
72
+ const hasAction = actionSlot && !disabled
73
+ const [hovered, setHovered] = useState(false)
74
+ const [focused, setFocused] = useState(false)
75
+ const showAction = hasAction && (hovered || focused)
76
+ const isSearch = type === 'search'
77
+ const isPassword = type === 'password'
78
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false)
79
+
80
+ // Debounce state
81
+ const [internalValue, setInternalValue] = useState(value)
82
+ const [debounceKey, setDebounceKey] = useState(0)
83
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
84
+
85
+ // Sync internalValue when prop value changes externally
86
+ useEffect(() => {
87
+ setInternalValue(value)
88
+ }, [value])
89
+
90
+ const displayValue = debounceMs > 0 ? internalValue : value
91
+
92
+ function handleChange(newValue: string) {
93
+ if (debounceMs > 0) {
94
+ setInternalValue(newValue)
95
+ setDebounceKey(k => k + 1)
96
+ clearTimeout(timerRef.current)
97
+ timerRef.current = setTimeout(() => {
98
+ onChange(newValue)
99
+ setDebounceKey(0)
100
+ }, debounceMs)
101
+ } else {
102
+ onChange(newValue)
103
+ }
104
+ }
105
+
106
+ function handleClear() {
107
+ clearTimeout(timerRef.current)
108
+ setInternalValue('')
109
+ setDebounceKey(0)
110
+ onChange('')
111
+ }
112
+
113
+ // Cleanup timer on unmount
114
+ useEffect(() => {
115
+ return () => clearTimeout(timerRef.current)
116
+ }, [])
117
+
118
+ const searchAutoProps = isSearch ? {
119
+ autoComplete: 'off' as const,
120
+ autoCorrect: 'off' as const,
121
+ autoCapitalize: 'off' as const,
122
+ spellCheck: false as const,
123
+ } : {}
124
+
125
+ const showClear = isSearch && displayValue && !disabled
126
+
127
+ return (
128
+ <div
129
+ className="w-full"
130
+ onMouseEnter={hasAction ? () => setHovered(true) : undefined}
131
+ onMouseLeave={hasAction ? () => setHovered(false) : undefined}
132
+ >
133
+ <div className="relative">
134
+ {isSearch && (
135
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-500 z-10 pointer-events-none" />
136
+ )}
137
+ <input
138
+ ref={ref}
139
+ type={isSearch ? 'text' : isPassword ? (isPasswordVisible ? 'text' : 'password') : type}
140
+ value={displayValue}
141
+ onChange={(e) => handleChange(e.target.value)}
142
+ onFocus={(e) => { setFocused(true); onFocusProp?.(e) }}
143
+ onBlur={(e) => { setFocused(false); onBlurProp?.(e) }}
144
+ disabled={disabled}
145
+ data-testid={testId}
146
+ {...searchAutoProps}
147
+ className={`
148
+ w-full border rounded-lg text-neutral-200 placeholder-neutral-500
149
+ focus:outline-none transition-colors
150
+ disabled:opacity-50 disabled:cursor-not-allowed
151
+ ${sizeClasses[size]}
152
+ ${hasError ? 'border-red-500 focus:border-red-500' : `${variantClasses[variant]} ${FORM_COLORS[color].border} ${FORM_COLORS[color].focus}`}
153
+ ${mono ? 'font-mono' : ''}
154
+ ${isSearch ? 'pl-8 pr-8' : (hasAction || isPassword) ? 'pr-8' : ''}
155
+ ${className}
156
+ `}
157
+ {...props}
158
+ />
159
+ {showClear && (
160
+ <button
161
+ type="button"
162
+ onClick={handleClear}
163
+ className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors z-10 cursor-pointer"
164
+ >
165
+ <X className="w-2.5 h-2.5" />
166
+ </button>
167
+ )}
168
+ {showAction && (
169
+ <div className="absolute right-1.5 top-1/2 -translate-y-1/2">
170
+ {actionSlot}
171
+ </div>
172
+ )}
173
+ {isPassword && !disabled && (
174
+ <button
175
+ type="button"
176
+ onClick={() => setIsPasswordVisible(!isPasswordVisible)}
177
+ title={isPasswordVisible ? 'Hide' : 'Reveal'}
178
+ className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors z-10 cursor-pointer"
179
+ >
180
+ {isPasswordVisible ? <EyeOff className="w-2.5 h-2.5" /> : <Eye className="w-2.5 h-2.5" />}
181
+ </button>
182
+ )}
183
+ {debounceMs > 0 && debounceKey > 0 && (
184
+ <svg
185
+ key={debounceKey}
186
+ className="absolute inset-0 pointer-events-none"
187
+ style={{ width: '100%', height: '100%' }}
188
+ >
189
+ <rect
190
+ x="1" y="1" rx="5" ry="5"
191
+ fill="none"
192
+ stroke="rgb(52 211 153 / 0.7)"
193
+ strokeWidth="1.5"
194
+ pathLength="100"
195
+ strokeDasharray="100"
196
+ strokeDashoffset="0"
197
+ style={{
198
+ width: 'calc(100% - 2px)',
199
+ height: 'calc(100% - 2px)',
200
+ animation: `debounce-border-drain ${debounceMs}ms linear forwards`,
201
+ }}
202
+ />
203
+ </svg>
204
+ )}
205
+ </div>
206
+ {typeof error === 'string' && error && (
207
+ <p className="text-xs text-red-400 mt-1 text-right">{error}</p>
208
+ )}
209
+ </div>
210
+ )
211
+ })
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Label - Border-focused colored badge/tag with icon and tooltip
3
+ *
4
+ * Single source of truth for all badge/tag patterns in the app.
5
+ * Matches the SeedrBadges border-focused style: colored border + text, no background.
6
+ *
7
+ * Features:
8
+ * - 14 color variants
9
+ * - Required leading icon and tooltip (always cursor-help)
10
+ * - Optional trailing icons
11
+ * - Text transform with smart capitalize (handles after-dash)
12
+ * - Clickable with hover state
13
+ */
14
+
15
+ import { iconMap, type IconName } from './icon-button.tsx'
16
+ import { Tooltip } from './tooltip.tsx'
17
+
18
+ export type LabelColor =
19
+ | 'neutral'
20
+ | 'green'
21
+ | 'red'
22
+ | 'blue'
23
+ | 'yellow'
24
+ | 'orange'
25
+ | 'purple'
26
+ | 'amber'
27
+ | 'emerald'
28
+ | 'cyan'
29
+ | 'indigo'
30
+ | 'teal'
31
+ | 'violet'
32
+ | 'pink'
33
+
34
+ export interface LabelProps {
35
+ text: string
36
+ color: LabelColor
37
+ /** Leading icon(s). Pass a single name or an array for multiple icons before text. */
38
+ icon?: IconName | IconName[]
39
+ /** Custom icon component. Takes precedence over icon. */
40
+ IconComponent?: React.ComponentType<{ className?: string }>
41
+ tooltip: { title?: string; description: string }
42
+ size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
43
+ textTransform?: 'capitalize' | 'lowercase' | 'uppercase'
44
+ onClick?: () => void
45
+ className?: string
46
+ testId?: string
47
+ }
48
+
49
+ const colorClasses: Record<LabelColor, string> = {
50
+ neutral: 'border-neutral-500/50 text-neutral-400',
51
+ green: 'border-green-500/50 text-green-400',
52
+ red: 'border-red-500/50 text-red-400',
53
+ blue: 'border-blue-500/50 text-blue-400',
54
+ yellow: 'border-yellow-500/50 text-yellow-400',
55
+ orange: 'border-orange-500/50 text-orange-400',
56
+ purple: 'border-purple-500/50 text-purple-400',
57
+ amber: 'border-amber-500/50 text-amber-400',
58
+ emerald: 'border-emerald-500/50 text-emerald-400',
59
+ cyan: 'border-cyan-500/50 text-cyan-400',
60
+ indigo: 'border-indigo-500/50 text-indigo-400',
61
+ teal: 'border-teal-500/50 text-teal-400',
62
+ violet: 'border-violet-500/50 text-violet-400',
63
+ pink: 'border-pink-500/50 text-pink-400',
64
+ }
65
+
66
+ const sizeConfig = {
67
+ xss: { height: 14, padding: 'px-1', text: 'text-[9px]', iconSize: 'w-2 h-2', gap: 'gap-0.5' },
68
+ xs: { height: 16, padding: 'px-1.5', text: 'text-[10px]', iconSize: 'w-2.5 h-2.5', gap: 'gap-1' },
69
+ sm: { height: 18, padding: 'px-1.5', text: 'text-[10px]', iconSize: 'w-2.5 h-2.5', gap: 'gap-1.5' },
70
+ md: { height: 20, padding: 'px-1.5', text: 'text-[11px]', iconSize: 'w-3 h-3', gap: 'gap-1' },
71
+ lg: { height: 22, padding: 'px-2', text: 'text-xs', iconSize: 'w-3 h-3', gap: 'gap-1' },
72
+ }
73
+
74
+ /** Smart capitalize: capitalizes first letter of each word separated by spaces or dashes */
75
+ function smartCapitalize(value: string): string {
76
+ return value.replace(/(^|[\s-])(\w)/g, (_match, separator: string, char: string) => separator + char.toUpperCase())
77
+ }
78
+
79
+ function transformText(text: string, textTransform?: 'capitalize' | 'lowercase' | 'uppercase'): string {
80
+ if (textTransform === 'capitalize') return smartCapitalize(text)
81
+ return text
82
+ }
83
+
84
+ export function Label({
85
+ text,
86
+ color,
87
+ icon,
88
+ IconComponent: CustomIcon,
89
+ tooltip,
90
+ size = 'sm',
91
+ textTransform,
92
+ onClick,
93
+ className = '',
94
+ testId,
95
+ }: LabelProps) {
96
+ const s = sizeConfig[size]
97
+ const cssTransform = textTransform === 'lowercase' || textTransform === 'uppercase' ? textTransform : undefined
98
+
99
+ const baseClasses = [
100
+ `inline-flex items-center ${s.gap}`,
101
+ s.padding,
102
+ s.text,
103
+ 'border rounded font-medium leading-none',
104
+ colorClasses[color],
105
+ onClick ? 'cursor-pointer hover:brightness-125 transition-all' : 'cursor-help',
106
+ className,
107
+ ]
108
+ .filter(Boolean)
109
+ .join(' ')
110
+
111
+ const icons: IconName[] = CustomIcon ? [] : !icon ? [] : Array.isArray(icon) ? icon : [icon]
112
+
113
+ const content = (
114
+ <>
115
+ {CustomIcon && (
116
+ <span className={`${s.iconSize} flex-shrink-0`}><CustomIcon className={s.iconSize} /></span>
117
+ )}
118
+ {icons.map((iconName, i) => {
119
+ const Icon = iconMap[iconName]
120
+ return Icon ? (
121
+ <span key={i} className={`${s.iconSize} flex-shrink-0`}><Icon className={s.iconSize} /></span>
122
+ ) : null
123
+ })}
124
+ <span className="min-w-0 truncate" style={cssTransform ? { textTransform: cssTransform } : undefined}>
125
+ {transformText(text, textTransform)}
126
+ </span>
127
+ </>
128
+ )
129
+
130
+ const labelElement = onClick ? (
131
+ <button
132
+ type="button"
133
+ onClick={(e) => { e.stopPropagation(); onClick() }}
134
+ data-testid={testId}
135
+ className={baseClasses}
136
+ style={{ height: `${s.height}px` }}
137
+ >
138
+ {content}
139
+ </button>
140
+ ) : (
141
+ <span
142
+ data-testid={testId}
143
+ className={baseClasses}
144
+ style={{ height: `${s.height}px` }}
145
+ >
146
+ {content}
147
+ </span>
148
+ )
149
+
150
+ return (
151
+ <Tooltip
152
+ content={tooltip}
153
+ position="top"
154
+ align="start"
155
+ >
156
+ {labelElement}
157
+ </Tooltip>
158
+ )
159
+ }
@@ -0,0 +1,289 @@
1
+ /** Browser-style chrome tab bar with two-line tabs, accent borders, gradient backgrounds, close on hover, scroll arrows, and drag reordering. */
2
+
3
+ import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react'
4
+ import { ChevronLeft, ChevronRight, X } from 'lucide-react'
5
+ import { cn } from '../lib/cn.ts'
6
+
7
+ export interface LayoutTab {
8
+ id: string
9
+ title: string
10
+ subtitle?: string
11
+ subtitleIcon?: ReactNode
12
+ icon?: ReactNode
13
+ color?: string
14
+ selectedColor?: string
15
+ titleColor?: string
16
+ iconColor?: string
17
+ subtitleColor?: string
18
+ subtitleIconColor?: string
19
+ closable?: boolean
20
+ }
21
+
22
+ export interface LayoutTabBarProps {
23
+ tabs: LayoutTab[]
24
+ activeId: string | null
25
+ onSelect: (id: string) => void
26
+ onClose?: (id: string) => void
27
+ onReorder?: (fromIndex: number, toIndex: number) => void
28
+ className?: string
29
+ }
30
+
31
+ const COLORS: Record<string, { border: string; text: string }> = {
32
+ white: { border: 'border-t-white', text: 'text-white' },
33
+ blue: { border: 'border-t-blue-400', text: 'text-blue-400' },
34
+ green: { border: 'border-t-green-400', text: 'text-green-400' },
35
+ purple: { border: 'border-t-purple-400', text: 'text-purple-400' },
36
+ orange: { border: 'border-t-orange-400', text: 'text-orange-400' },
37
+ red: { border: 'border-t-red-400', text: 'text-red-400' },
38
+ cyan: { border: 'border-t-cyan-400', text: 'text-cyan-400' },
39
+ amber: { border: 'border-t-amber-400', text: 'text-amber-400' },
40
+ emerald: { border: 'border-t-emerald-400', text: 'text-emerald-400' },
41
+ teal: { border: 'border-t-teal-400', text: 'text-teal-400' },
42
+ indigo: { border: 'border-t-indigo-400', text: 'text-indigo-400' },
43
+ violet: { border: 'border-t-violet-400', text: 'text-violet-400' },
44
+ pink: { border: 'border-t-pink-400', text: 'text-pink-400' },
45
+ neutral: { border: 'border-t-neutral-500', text: 'text-neutral-400' },
46
+ }
47
+
48
+ // RGB values for gradient computation (Tailwind 400-level)
49
+ const COLOR_RGB: Record<string, string> = {
50
+ blue: '96, 165, 250',
51
+ green: '74, 222, 128',
52
+ purple: '192, 132, 252',
53
+ orange: '251, 146, 60',
54
+ red: '248, 113, 113',
55
+ cyan: '34, 211, 238',
56
+ amber: '251, 191, 36',
57
+ emerald: '52, 211, 153',
58
+ teal: '45, 212, 191',
59
+ indigo: '129, 140, 248',
60
+ violet: '167, 139, 250',
61
+ pink: '244, 114, 182',
62
+ }
63
+
64
+ function getTextClass(color?: string): string {
65
+ return color && COLORS[color] ? COLORS[color].text : ''
66
+ }
67
+
68
+ function getBorderClass(color?: string): string {
69
+ return color && COLORS[color] ? COLORS[color].border : COLORS.neutral.border
70
+ }
71
+
72
+ function getGradient(color?: string, isActive?: boolean): string | undefined {
73
+ if (!color || !COLOR_RGB[color]) return undefined
74
+ const rgb = COLOR_RGB[color]
75
+ return isActive
76
+ ? `linear-gradient(135deg, rgba(${rgb}, 0.54) 0%, rgba(${rgb}, 0.10) 100%)`
77
+ : `linear-gradient(135deg, rgba(${rgb}, 0.36) 0%, rgba(${rgb}, 0.06) 100%)`
78
+ }
79
+
80
+ export function LayoutTabBar({ tabs, activeId, onSelect, onClose, onReorder, className }: LayoutTabBarProps) {
81
+ const scrollRef = useRef<HTMLDivElement>(null)
82
+ const [showLeft, setShowLeft] = useState(false)
83
+ const [showRight, setShowRight] = useState(false)
84
+ const [drag, setDrag] = useState<{ from: number; insertAt: number | null; x: number; y: number } | null>(null)
85
+ const skipClickRef = useRef(false)
86
+
87
+ // ── Scroll arrows ──────────────────────────────────────
88
+
89
+ const updateArrows = useCallback(() => {
90
+ const el = scrollRef.current
91
+ if (!el) return
92
+ setShowLeft(el.scrollLeft > 0)
93
+ setShowRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1)
94
+ }, [])
95
+
96
+ useEffect(() => {
97
+ const el = scrollRef.current
98
+ if (!el) return
99
+ updateArrows()
100
+ el.addEventListener('scroll', updateArrows)
101
+ const ro = new ResizeObserver(updateArrows)
102
+ ro.observe(el)
103
+ return () => { el.removeEventListener('scroll', updateArrows); ro.disconnect() }
104
+ }, [updateArrows, tabs.length])
105
+
106
+ useEffect(() => {
107
+ if (!activeId || !scrollRef.current) return
108
+ const idx = tabs.findIndex(t => t.id === activeId)
109
+ const child = scrollRef.current.children[idx] as HTMLElement | undefined
110
+ child?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
111
+ }, [activeId, tabs])
112
+
113
+ const scroll = useCallback((dir: 'left' | 'right') => {
114
+ scrollRef.current?.scrollBy({ left: dir === 'left' ? -200 : 200, behavior: 'smooth' })
115
+ }, [])
116
+
117
+ // ── Drag and drop ─────────────────────────────────────
118
+
119
+ const handleMouseDown = useCallback((e: React.MouseEvent, index: number) => {
120
+ if (e.button !== 0 || !onReorder) return
121
+ if ((e.target as HTMLElement).closest('[role="button"]')) return
122
+ const sx = e.clientX, sy = e.clientY
123
+ let active = false
124
+ let lastInsert: number | null = null
125
+
126
+ const findInsert = (cx: number): number | null => {
127
+ const el = scrollRef.current
128
+ if (!el) return null
129
+ const kids = el.children
130
+ for (let i = 0; i < kids.length; i++) {
131
+ const r = kids[i].getBoundingClientRect()
132
+ if (cx >= r.left && cx <= r.right) return cx < r.left + r.width / 2 ? i : i + 1
133
+ }
134
+ if (kids.length) {
135
+ const last = kids[kids.length - 1].getBoundingClientRect()
136
+ if (cx > last.right) return kids.length
137
+ }
138
+ return null
139
+ }
140
+
141
+ const onMove = (e: MouseEvent) => {
142
+ if (!active) {
143
+ const dx = e.clientX - sx, dy = e.clientY - sy
144
+ if (Math.sqrt(dx * dx + dy * dy) > 5) {
145
+ active = true
146
+ skipClickRef.current = true
147
+ } else return
148
+ }
149
+ lastInsert = findInsert(e.clientX)
150
+ setDrag({ from: index, insertAt: lastInsert, x: e.clientX, y: e.clientY })
151
+ }
152
+
153
+ const onUp = () => {
154
+ window.removeEventListener('mousemove', onMove)
155
+ window.removeEventListener('mouseup', onUp)
156
+ if (active && lastInsert !== null && lastInsert !== index && lastInsert !== index + 1) {
157
+ onReorder(index, lastInsert > index ? lastInsert - 1 : lastInsert)
158
+ }
159
+ setDrag(null)
160
+ setTimeout(() => { skipClickRef.current = false }, 50)
161
+ }
162
+
163
+ window.addEventListener('mousemove', onMove)
164
+ window.addEventListener('mouseup', onUp)
165
+ }, [onReorder])
166
+
167
+ // ── Render ────────────────────────────────────────────
168
+
169
+ return (
170
+ <div className={cn('relative flex items-center min-h-12 flex-shrink-0 bg-neutral-900/80 border-b border-neutral-700/50', className)}>
171
+ {showLeft && (
172
+ <div className="flex items-center px-2 shrink-0">
173
+ <button type="button" onClick={() => scroll('left')} className="text-neutral-500 hover:text-neutral-400 transition-colors cursor-pointer">
174
+ <ChevronLeft className="w-3.5 h-3.5" />
175
+ </button>
176
+ </div>
177
+ )}
178
+
179
+ <div ref={scrollRef} className="flex items-end gap-1 px-3 overflow-x-auto flex-1 h-full" style={{ scrollbarWidth: 'none' }}>
180
+ {tabs.map((tab, i) => {
181
+ const isActive = tab.id === activeId
182
+ const borderC = getBorderClass(tab.selectedColor || tab.color)
183
+ const titleC = getTextClass(tab.titleColor || 'white')
184
+ const iconC = getTextClass(tab.iconColor || tab.titleColor || 'white')
185
+ const subC = getTextClass(tab.subtitleColor || tab.selectedColor || tab.color)
186
+ const subIconC = getTextClass(tab.subtitleIconColor || tab.subtitleColor || tab.selectedColor || tab.color)
187
+ const isDragged = drag?.from === i
188
+ const insertHere = drag && drag.insertAt === i && drag.from !== i && drag.from !== i - 1
189
+ const insertAfterLast = drag && drag.insertAt === tabs.length && i === tabs.length - 1 && drag.from !== i
190
+
191
+ return (
192
+ <div key={tab.id} className="relative flex-shrink-0">
193
+ {insertHere && <div className="absolute -left-1 top-0 bottom-0 w-0.5 bg-blue-500 rounded-full z-10" />}
194
+
195
+ <button
196
+ type="button"
197
+ onClick={() => { if (!skipClickRef.current) onSelect(tab.id) }}
198
+ onMouseDown={(e) => {
199
+ if (e.button === 1 && onClose && tab.closable) {
200
+ e.preventDefault()
201
+ onClose(tab.id)
202
+ } else {
203
+ handleMouseDown(e, i)
204
+ }
205
+ }}
206
+ className={cn(
207
+ 'group flex flex-col justify-start gap-0.5 px-2.5 rounded-t-lg cursor-pointer transition-all shrink-0 select-none relative border-t-2',
208
+ isActive
209
+ ? cn('bg-neutral-800', borderC)
210
+ : 'bg-neutral-800/50 text-neutral-400 hover:bg-neutral-800/80 hover:text-neutral-300 border-transparent opacity-60 hover:opacity-90',
211
+ isDragged && 'opacity-50',
212
+ )}
213
+ style={{
214
+ minWidth: 160,
215
+ maxWidth: 260,
216
+ height: 42,
217
+ backgroundImage: getGradient(tab.color, isActive),
218
+ }}
219
+ >
220
+ {/* Line 1: Icon + Title */}
221
+ <div className="flex items-center gap-1.5 w-full transition-[padding] duration-150 group-hover:pr-5">
222
+ {tab.icon && <span className={cn('shrink-0 inline-flex', isActive ? iconC : 'text-neutral-400')} style={{ width: 14, height: 14 }}>{tab.icon}</span>}
223
+ <span className={cn('truncate text-sm font-medium', isActive ? titleC : 'text-neutral-300')}>{tab.title}</span>
224
+ </div>
225
+
226
+ {/* Line 2: Subtitle with optional icon */}
227
+ {tab.subtitle && (
228
+ <div className="flex items-center gap-1 w-full">
229
+ {tab.subtitleIcon && <span className={cn('shrink-0 inline-flex', isActive ? subIconC : 'text-neutral-500')} style={{ width: 10, height: 10 }}>{tab.subtitleIcon}</span>}
230
+ <span className={cn('truncate text-xs', isActive ? subC : 'text-neutral-500')}>{tab.subtitle}</span>
231
+ </div>
232
+ )}
233
+
234
+ {/* Close button */}
235
+ {tab.closable && onClose && (
236
+ <span
237
+ role="button"
238
+ tabIndex={-1}
239
+ onClick={(e) => { e.stopPropagation(); onClose(tab.id) }}
240
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onClose(tab.id) } }}
241
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 hover:bg-neutral-700 rounded p-0.5 transition-opacity cursor-pointer"
242
+ >
243
+ <X className="w-3 h-3" />
244
+ </span>
245
+ )}
246
+ </button>
247
+
248
+ {insertAfterLast && <div className="absolute -right-1 top-0 bottom-0 w-0.5 bg-blue-500 rounded-full z-10" />}
249
+ </div>
250
+ )
251
+ })}
252
+ </div>
253
+
254
+ {showRight && (
255
+ <div className="flex items-center px-2 shrink-0">
256
+ <button type="button" onClick={() => scroll('right')} className="text-neutral-500 hover:text-neutral-400 transition-colors cursor-pointer">
257
+ <ChevronRight className="w-3.5 h-3.5" />
258
+ </button>
259
+ </div>
260
+ )}
261
+
262
+ {/* Drag ghost */}
263
+ {drag && tabs[drag.from] && (() => {
264
+ const t = tabs[drag.from]
265
+ const ghostBorder = getBorderClass(t.selectedColor || t.color)
266
+ const ghostTitleC = getTextClass(t.titleColor || 'white')
267
+ const ghostIconC = getTextClass(t.iconColor || t.titleColor || 'white')
268
+ const ghostSubC = getTextClass(t.subtitleColor || t.selectedColor || t.color)
269
+ const ghostSubIconC = getTextClass(t.subtitleIconColor || t.subtitleColor || t.selectedColor || t.color)
270
+ return (
271
+ <div className="fixed z-50 pointer-events-none opacity-90" style={{ left: drag.x + 12, top: drag.y - 20 }}>
272
+ <div className={cn('flex flex-col gap-0.5 px-2.5 py-1.5 rounded-t-lg border-t-2 shadow-xl bg-neutral-800', ghostBorder)}>
273
+ <div className="flex items-center gap-1.5">
274
+ {t.icon && <span className={cn('shrink-0 inline-flex', ghostIconC)} style={{ width: 14, height: 14 }}>{t.icon}</span>}
275
+ <span className={cn('text-sm font-medium', ghostTitleC)}>{t.title}</span>
276
+ </div>
277
+ {t.subtitle && (
278
+ <div className="flex items-center gap-1">
279
+ {t.subtitleIcon && <span className={cn('shrink-0 inline-flex', ghostSubIconC)} style={{ width: 10, height: 10 }}>{t.subtitleIcon}</span>}
280
+ <span className={cn('text-xs', ghostSubC)}>{t.subtitle}</span>
281
+ </div>
282
+ )}
283
+ </div>
284
+ </div>
285
+ )
286
+ })()}
287
+ </div>
288
+ )
289
+ }