@toolr/ui-design 0.1.6 → 0.1.8

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 (61) hide show
  1. package/components/hooks/use-click-outside.ts +10 -3
  2. package/components/hooks/use-modal-behavior.ts +53 -0
  3. package/components/hooks/use-navigation-history.ts +7 -2
  4. package/components/hooks/use-resizable-sidebar.ts +38 -0
  5. package/components/lib/form-colors.ts +40 -0
  6. package/components/sections/captured-issues/captured-issues-panel.tsx +3 -3
  7. package/components/sections/captured-issues/use-captured-issues.ts +9 -3
  8. package/components/sections/golden-snapshots/file-diff-viewer.tsx +1 -1
  9. package/components/sections/golden-snapshots/status-overview.tsx +1 -1
  10. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +4 -40
  11. package/components/sections/prompt-editor/index.ts +0 -7
  12. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +4 -40
  13. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +4 -36
  14. package/components/sections/snippets-editor/snippets-editor.tsx +6 -39
  15. package/components/settings/SettingsHeader.tsx +0 -1
  16. package/components/settings/SettingsTreeNav.tsx +9 -12
  17. package/components/ui/action-dialog.tsx +19 -55
  18. package/components/ui/ai-action-button.tsx +2 -4
  19. package/components/ui/badge.tsx +15 -23
  20. package/components/ui/breadcrumb.tsx +11 -71
  21. package/components/ui/checkbox.tsx +19 -27
  22. package/components/ui/collapsible-section.tsx +4 -41
  23. package/components/ui/confirm-badge.tsx +14 -23
  24. package/components/ui/cookie-consent.tsx +18 -2
  25. package/components/ui/debounce-border-overlay.tsx +31 -0
  26. package/components/ui/detail-section.tsx +2 -19
  27. package/components/ui/editor-placeholder-card.tsx +10 -9
  28. package/components/ui/execution-details-panel.tsx +2 -7
  29. package/components/ui/extension-list-card.tsx +1 -1
  30. package/components/ui/file-structure-section.tsx +3 -18
  31. package/components/ui/file-tree.tsx +6 -18
  32. package/components/ui/files-panel.tsx +3 -11
  33. package/components/ui/filter-dropdown.tsx +5 -2
  34. package/components/ui/form-actions.tsx +11 -8
  35. package/components/ui/icon-button.tsx +7 -6
  36. package/components/ui/input.tsx +18 -29
  37. package/components/ui/label.tsx +7 -17
  38. package/components/ui/layout-tab-bar.tsx +5 -5
  39. package/components/ui/modal.tsx +10 -18
  40. package/components/ui/nav-card.tsx +3 -18
  41. package/components/ui/navigation-bar.tsx +12 -73
  42. package/components/ui/number-input.tsx +6 -0
  43. package/components/ui/registry-browser.tsx +6 -20
  44. package/components/ui/registry-card.tsx +3 -7
  45. package/components/ui/resizable-textarea.tsx +13 -35
  46. package/components/ui/segmented-toggle.tsx +4 -1
  47. package/components/ui/select.tsx +8 -14
  48. package/components/ui/selection-grid.tsx +6 -50
  49. package/components/ui/setting-row.tsx +5 -5
  50. package/components/ui/settings-card.tsx +2 -2
  51. package/components/ui/settings-info-box.tsx +6 -24
  52. package/components/ui/sort-dropdown.tsx +8 -5
  53. package/components/ui/status-card.tsx +2 -13
  54. package/components/ui/tab-bar.tsx +17 -33
  55. package/components/ui/toggle.tsx +22 -30
  56. package/components/ui/tooltip.tsx +11 -23
  57. package/dist/index.d.ts +71 -142
  58. package/dist/index.js +1630 -2436
  59. package/index.ts +8 -7
  60. package/package.json +9 -1
  61. package/components/sections/prompt-editor/use-prompt-editor.ts +0 -131
@@ -1,58 +1,14 @@
1
1
  /** Navigation bar with back/forward controls, history dropdown, and inline breadcrumb path. */
2
2
 
3
3
  import { useState, useRef, useCallback } from 'react'
4
- import {
5
- ChevronLeft, ChevronRight, History,
6
- Menu, Home, Layers,
7
- Settings, Folder, File, Code, Terminal, Database,
8
- Globe, Star, Users, User, Tag, Search, Heart,
9
- Zap, Shield, ShieldCheck, Sparkles, Eye, Lock,
10
- Cloud, Wand2, Bell, Bookmark, Pin, Mail, Send,
11
- Image, Bot, Puzzle, Plug, Webhook,
12
- } from 'lucide-react'
4
+ import { ChevronLeft, ChevronRight, History } from 'lucide-react'
13
5
  import type { LucideIcon } from 'lucide-react'
14
- import type { IconName } from './icon-button.tsx'
6
+ import { iconMap, type IconName } from './icon-button.tsx'
15
7
  import type { BreadcrumbSegment } from './breadcrumb.tsx'
8
+ import { ACCENT_NAV, type AccentColor } from '../lib/form-colors.ts'
16
9
  import { cn } from '../lib/cn.ts'
17
10
  import { useClickOutside } from '../hooks/use-click-outside.ts'
18
11
 
19
- const iconSubset: Partial<Record<IconName, LucideIcon>> = {
20
- menu: Menu,
21
- home: Home,
22
- layers: Layers,
23
- folder: Folder,
24
- file: File,
25
- settings: Settings,
26
- code: Code,
27
- terminal: Terminal,
28
- database: Database,
29
- globe: Globe,
30
- star: Star,
31
- users: Users,
32
- user: User,
33
- tag: Tag,
34
- zap: Zap,
35
- shield: Shield,
36
- 'shield-check': ShieldCheck,
37
- sparkles: Sparkles,
38
- eye: Eye,
39
- lock: Lock,
40
- search: Search,
41
- heart: Heart,
42
- cloud: Cloud,
43
- wand: Wand2,
44
- bell: Bell,
45
- bookmark: Bookmark,
46
- pin: Pin,
47
- mail: Mail,
48
- send: Send,
49
- image: Image,
50
- bot: Bot,
51
- puzzle: Puzzle,
52
- plug: Plug,
53
- webhook: Webhook,
54
- }
55
-
56
12
  export interface NavigationBarProps {
57
13
  segments: BreadcrumbSegment[]
58
14
  canGoBack?: boolean
@@ -76,23 +32,6 @@ const sizeConfig = {
76
32
  lg: { text: 'text-lg', segIcon: 'w-5 h-5', navIcon: 'w-5 h-5', navBtn: 'w-9 h-9 rounded-md', px: 'px-3', py: 'py-1.5', sep: 'w-4 h-4', divH: 'h-6' },
77
33
  }
78
34
 
79
- const colorMap: Record<string, { bg: string; text: string }> = {
80
- blue: { bg: 'bg-blue-500/10', text: 'text-blue-400' },
81
- green: { bg: 'bg-green-500/10', text: 'text-green-400' },
82
- purple: { bg: 'bg-purple-500/10', text: 'text-purple-400' },
83
- red: { bg: 'bg-red-500/10', text: 'text-red-400' },
84
- orange: { bg: 'bg-orange-500/10', text: 'text-orange-400' },
85
- cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-400' },
86
- yellow: { bg: 'bg-yellow-500/10', text: 'text-yellow-400' },
87
- amber: { bg: 'bg-amber-500/10', text: 'text-amber-400' },
88
- emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
89
- indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' },
90
- violet: { bg: 'bg-violet-500/10', text: 'text-violet-400' },
91
- sky: { bg: 'bg-sky-500/10', text: 'text-sky-400' },
92
- pink: { bg: 'bg-pink-500/10', text: 'text-pink-400' },
93
- teal: { bg: 'bg-teal-500/10', text: 'text-teal-400' },
94
- neutral: { bg: 'bg-neutral-500/10', text: 'text-neutral-400' },
95
- }
96
35
 
97
36
  function NavButton({ icon: Icon, onClick, disabled, size, active }: {
98
37
  icon: LucideIcon
@@ -134,9 +73,9 @@ function SegmentSeparator({ type, size }: { type: 'chevron' | 'slash' | 'dot'; s
134
73
  }
135
74
 
136
75
  function SegmentIcon({ icon, color, size }: { icon: IconName; color?: string; size: keyof typeof sizeConfig }) {
137
- const Icon = iconSubset[icon]
76
+ const Icon = iconMap[icon]
138
77
  if (!Icon) return null
139
- const c = color && colorMap[color] ? colorMap[color] : null
78
+ const c = color && ACCENT_NAV[color as AccentColor] ? ACCENT_NAV[color as AccentColor] : null
140
79
  return (
141
80
  <span className={c?.text || ''}>
142
81
  <Icon className={sizeConfig[size].segIcon} />
@@ -160,7 +99,7 @@ export function NavigationBar({
160
99
  }: NavigationBarProps) {
161
100
  const s = sizeConfig[size]
162
101
  const hasNav = !!(onBack || onForward)
163
- const LeadIcon = leadingAction ? iconSubset[leadingAction.icon] : null
102
+ const LeadIcon = leadingAction ? iconMap[leadingAction.icon] : null
164
103
 
165
104
  const [historyOpen, setHistoryOpen] = useState(false)
166
105
  const historyRef = useRef<HTMLDivElement>(null)
@@ -200,7 +139,7 @@ export function NavigationBar({
200
139
  active={historyOpen}
201
140
  />
202
141
  {historyOpen && hasHistoryEntries && (
203
- <div className="absolute left-0 top-full mt-1 w-max min-w-[200px] max-w-[420px] bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-50">
142
+ <div className="absolute left-0 top-full mt-1 w-max min-w-[200px] max-w-[420px] bg-neutral-800 border border-neutral-700 rounded-lg shadow-lg z-50">
204
143
  <div className="px-3 py-1.5 border-b border-neutral-700/50">
205
144
  <p className="text-sm font-medium text-neutral-500">History</p>
206
145
  </div>
@@ -221,7 +160,7 @@ export function NavigationBar({
221
160
  {seg.icon && <SegmentIcon icon={seg.icon} color={seg.color} size="xs" />}
222
161
  <span className={cn(
223
162
  'text-sm truncate',
224
- seg.color && colorMap[seg.color] ? colorMap[seg.color].text : 'text-neutral-300',
163
+ seg.color && ACCENT_NAV[seg.color as AccentColor] ? ACCENT_NAV[seg.color as AccentColor].text : 'text-neutral-300',
225
164
  )}>
226
165
  {seg.label}
227
166
  </span>
@@ -243,17 +182,17 @@ export function NavigationBar({
243
182
  {segments.map((segment, index) => {
244
183
  const isLast = index === segments.length - 1
245
184
  const isClickable = !isLast && !!segment.onClick
246
- const colors = segment.color && colorMap[segment.color] ? colorMap[segment.color] : null
185
+ const colors = segment.color && ACCENT_NAV[segment.color as AccentColor] ? ACCENT_NAV[segment.color as AccentColor] : null
247
186
 
248
187
  return (
249
- <div key={segment.id} className="flex items-center gap-1">
188
+ <div key={segment.id} className="flex items-center gap-1 min-w-0">
250
189
  {index > 0 && <SegmentSeparator type={separator} size={size} />}
251
190
  {isClickable ? (
252
191
  <button
253
192
  type="button"
254
193
  onClick={segment.onClick}
255
194
  className={cn(
256
- 'flex items-center gap-1.5 px-2 py-0.5 rounded-md transition-colors cursor-pointer',
195
+ 'flex items-center gap-1.5 px-2 py-0.5 rounded-md transition-colors cursor-pointer min-w-0',
257
196
  s.text,
258
197
  'font-medium hover:text-white',
259
198
  colors ? [colors.text, `hover:${colors.bg}`] : ['text-neutral-300', 'hover:bg-neutral-700/50'],
@@ -265,7 +204,7 @@ export function NavigationBar({
265
204
  ) : (
266
205
  <div
267
206
  className={cn(
268
- 'flex items-center gap-1.5 px-2 py-0.5 rounded-md',
207
+ 'flex items-center gap-1.5 px-2 py-0.5 rounded-md min-w-0',
269
208
  s.text,
270
209
  isLast
271
210
  ? ['font-medium bg-neutral-700/50', colors ? colors.text : 'text-white']
@@ -13,6 +13,8 @@ export interface NumberInputProps {
13
13
  size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
14
14
  disabled?: boolean
15
15
  className?: string
16
+ /** Accessible label — required for screen readers */
17
+ 'aria-label'?: string
16
18
  }
17
19
 
18
20
  const SIZE_CONFIG = {
@@ -39,6 +41,7 @@ export function NumberInput({
39
41
  size = 'sm',
40
42
  disabled = false,
41
43
  className = '',
44
+ 'aria-label': ariaLabel,
42
45
  }: NumberInputProps) {
43
46
  const [focused, setFocused] = useState(false)
44
47
  const [editText, setEditText] = useState<string | null>(null)
@@ -92,6 +95,7 @@ export function NumberInput({
92
95
  ref={inputRef}
93
96
  type="text"
94
97
  inputMode="numeric"
98
+ aria-label={ariaLabel}
95
99
  value={editText ?? value}
96
100
  onChange={(e) => setEditText(e.target.value)}
97
101
  onFocus={() => {
@@ -128,6 +132,7 @@ export function NumberInput({
128
132
  >
129
133
  <button
130
134
  type="button"
135
+ aria-label="Increase value"
131
136
  tabIndex={-1}
132
137
  onMouseDown={(e) => e.preventDefault()}
133
138
  onClick={() => nudge(1)}
@@ -145,6 +150,7 @@ export function NumberInput({
145
150
  <div className={`border-t ${fc.border}`} />
146
151
  <button
147
152
  type="button"
153
+ aria-label="Decrease value"
148
154
  tabIndex={-1}
149
155
  onMouseDown={(e) => e.preventDefault()}
150
156
  onClick={() => nudge(-1)}
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode, useState, useEffect, useRef, useCallback } from 'react'
2
2
  import { Search, ArrowRight, RefreshCw, Loader2, X, AlertTriangle } from 'lucide-react'
3
+ import { DebounceBorderOverlay } from './debounce-border-overlay.tsx'
3
4
  import { IconButton } from './icon-button.tsx'
4
5
  import { Input } from './input.tsx'
5
6
  import { RegistryCard, type RegistryCardProps } from './registry-card.tsx'
@@ -123,6 +124,10 @@ export function RegistryBrowser({
123
124
  }, 150)
124
125
  }, [onScrollChange])
125
126
 
127
+ useEffect(() => {
128
+ return () => clearTimeout(scrollTimerRef.current)
129
+ }, [])
130
+
126
131
  const loadMore = useCallback(() => {
127
132
  setVisibleCount((prev: number) => Math.min(prev + PAGE_SIZE, totalCount))
128
133
  }, [totalCount])
@@ -170,26 +175,7 @@ export function RegistryBrowser({
170
175
  </button>
171
176
  )}
172
177
  {debounceKey != null && debounceKey > 0 && (
173
- <svg
174
- key={debounceKey}
175
- className="absolute inset-0 pointer-events-none text-emerald-400/70"
176
- style={{ width: '100%', height: '100%' }}
177
- >
178
- <rect
179
- x="1" y="1" rx="5" ry="5"
180
- fill="none"
181
- stroke="currentColor"
182
- strokeWidth="1.5"
183
- pathLength="100"
184
- strokeDasharray="100"
185
- strokeDashoffset="0"
186
- style={{
187
- width: 'calc(100% - 2px)',
188
- height: 'calc(100% - 2px)',
189
- animation: `debounce-border-drain ${debounceDurationMs}ms linear forwards`,
190
- }}
191
- />
192
- </svg>
178
+ <DebounceBorderOverlay debounceKey={debounceKey} durationMs={debounceDurationMs} />
193
179
  )}
194
180
  </div>
195
181
 
@@ -5,7 +5,7 @@ import {
5
5
  } from 'lucide-react'
6
6
  import { Tooltip } from './tooltip.tsx'
7
7
  import { IconButton, type IconName } from './icon-button.tsx'
8
- import { Label, type LabelColor } from './label.tsx'
8
+ import { Label, type LabelColor, smartCapitalize } from './label.tsx'
9
9
  import { AiToolIcon, AI_TOOL_NAMES, type AiToolKey } from '../lib/ai-tools.tsx'
10
10
  export { AiToolIcon, AI_TOOL_NAMES, type AiToolKey }
11
11
 
@@ -72,10 +72,6 @@ function getCategoryLabelColor(category: string): LabelColor {
72
72
  return CATEGORY_LABEL_COLORS[Math.abs(hash) % CATEGORY_LABEL_COLORS.length]
73
73
  }
74
74
 
75
- export function formatCategory(category: string): string {
76
- return category.replace(/(^|-)(\w)/g, (_, sep, ch) => sep + ch.toUpperCase())
77
- }
78
-
79
75
  // ── Time helpers ──────────────────────────────────────────────────────────────
80
76
 
81
77
  function formatRelativeTime(isoDate: string): string {
@@ -153,10 +149,10 @@ function CardClickable({ children, onClick }: { children: ReactNode; onClick: (e
153
149
  function CategoryBadge({ category, onFilter }: { category: string; onFilter?: (category: string) => void }) {
154
150
  return (
155
151
  <Label
156
- text={formatCategory(category)}
152
+ text={smartCapitalize(category)}
157
153
  color={getCategoryLabelColor(category)}
158
154
  icon="tag"
159
- tooltip={{ description: onFilter ? `${formatCategory(category)} \u00b7 Click to filter` : formatCategory(category) }}
155
+ tooltip={{ description: onFilter ? `${smartCapitalize(category)} \u00b7 Click to filter` : smartCapitalize(category) }}
160
156
  size="sm"
161
157
  onClick={onFilter ? () => onFilter(category) : undefined}
162
158
  />
@@ -115,10 +115,10 @@ function useResize(minHeight: number, onHeightChange?: (height: number) => void)
115
115
  // Code variant — Monaco editor with resize handle
116
116
  // ---------------------------------------------------------------------------
117
117
 
118
- const MONACO_THEME = 'resizable-textarea-dark'
119
- let themeRegistered = false
118
+ const MONACO_THEME_PREFIX = 'resizable-textarea'
119
+ const registeredThemes = new Set<string>()
120
120
 
121
- const codeWrapperClasses = {
121
+ const wrapperVariantClasses = {
122
122
  filled: 'bg-neutral-800 border rounded-lg overflow-hidden',
123
123
  outline: 'bg-transparent border rounded-lg overflow-hidden',
124
124
  }
@@ -139,7 +139,7 @@ function ResizableCode({
139
139
 
140
140
  return (
141
141
  <div
142
- className={`relative ${codeWrapperClasses[variant]} ${FORM_COLORS[color].border} ${wrapperClassName ?? ''}`}
142
+ className={`relative ${wrapperVariantClasses[variant]} ${FORM_COLORS[color].border} ${wrapperClassName ?? ''}`}
143
143
  data-resizable-wrapper
144
144
  style={{ height: height ?? minHeight }}
145
145
  >
@@ -148,7 +148,7 @@ function ResizableCode({
148
148
  language={language}
149
149
  value={value}
150
150
  onChange={(v) => onChange?.(v ?? '')}
151
- theme={MONACO_THEME}
151
+ theme={`${MONACO_THEME_PREFIX}-${variant}`}
152
152
  options={{
153
153
  minimap: { enabled: false },
154
154
  fontSize: 13,
@@ -170,8 +170,9 @@ function ResizableCode({
170
170
  glyphMargin: false,
171
171
  }}
172
172
  beforeMount={(monaco) => {
173
- if (!themeRegistered) {
174
- monaco.editor.defineTheme(MONACO_THEME, {
173
+ const themeName = `${MONACO_THEME_PREFIX}-${variant}`
174
+ if (!registeredThemes.has(themeName)) {
175
+ monaco.editor.defineTheme(themeName, {
175
176
  base: 'vs-dark',
176
177
  inherit: true,
177
178
  rules: [],
@@ -182,7 +183,7 @@ function ResizableCode({
182
183
  'editor.lineHighlightBorder': '#00000000',
183
184
  },
184
185
  })
185
- themeRegistered = true
186
+ registeredThemes.add(themeName)
186
187
  }
187
188
  }}
188
189
  />
@@ -202,11 +203,6 @@ function ResizableCode({
202
203
  // Children variant — wraps any element with resize handle
203
204
  // ---------------------------------------------------------------------------
204
205
 
205
- const childrenVariantClasses = {
206
- filled: 'bg-neutral-800 border rounded-lg overflow-hidden',
207
- outline: 'bg-transparent border rounded-lg overflow-hidden',
208
- }
209
-
210
206
  function ResizableChildren({
211
207
  children,
212
208
  wrapperClassName,
@@ -224,7 +220,7 @@ function ResizableChildren({
224
220
 
225
221
  return (
226
222
  <div
227
- className={`relative ${childrenVariantClasses[variant]} ${FORM_COLORS[color].border} ${wrapperClassName ?? ''}`}
223
+ className={`relative ${wrapperVariantClasses[variant]} ${FORM_COLORS[color].border} ${wrapperClassName ?? ''}`}
228
224
  data-resizable-wrapper
229
225
  style={height != null ? { height } : undefined}
230
226
  >
@@ -250,25 +246,7 @@ function ResizableField({
250
246
  color = 'blue',
251
247
  ...props
252
248
  }: ResizableTextareaBaseProps & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'color'>) {
253
- const taRef = useRef<HTMLTextAreaElement>(null)
254
- const dragRef = useRef<{ startY: number; startH: number } | null>(null)
255
- const onResizeStart = useCallback((e: React.MouseEvent) => {
256
- e.preventDefault()
257
- const ta = taRef.current
258
- if (!ta) return
259
- dragRef.current = { startY: e.clientY, startH: ta.offsetHeight }
260
- const onMove = (ev: MouseEvent) => {
261
- if (!dragRef.current || !ta) return
262
- ta.style.height = `${Math.max(40, dragRef.current.startH + ev.clientY - dragRef.current.startY)}px`
263
- }
264
- const onUp = () => {
265
- dragRef.current = null
266
- document.removeEventListener('mousemove', onMove)
267
- document.removeEventListener('mouseup', onUp)
268
- }
269
- document.addEventListener('mousemove', onMove)
270
- document.addEventListener('mouseup', onUp)
271
- }, [])
249
+ const { height, onResizeStart } = useResize(40)
272
250
 
273
251
  const className = `w-full rounded-lg focus:outline-none transition-colors ${variantClasses[variant]} ${FORM_COLORS[color].border} ${FORM_COLORS[color].focus} ${props.className ?? ''}`
274
252
 
@@ -277,8 +255,8 @@ function ResizableField({
277
255
  }
278
256
 
279
257
  return (
280
- <div className={`relative ${wrapperClassName ?? ''}`}>
281
- <textarea ref={taRef} {...props} className={className} style={{ resize: 'none', ...props.style }} />
258
+ <div className={`relative ${wrapperClassName ?? ''}`} data-resizable-wrapper>
259
+ <textarea {...props} className={className} style={{ resize: 'none', ...props.style, ...(height != null ? { height } : {}) }} />
282
260
  <div
283
261
  className="absolute bottom-[8px] right-[3px] w-4 h-3 cursor-row-resize flex items-end justify-end"
284
262
  onMouseDown={onResizeStart}
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react'
2
+ import type { AccentColor } from '../lib/form-colors.ts'
2
3
  import { Tooltip, type TooltipContent, type TooltipPosition } from './tooltip.tsx'
3
4
 
4
5
  export interface SegmentedToggleOption<T extends string> {
@@ -12,7 +13,7 @@ export interface SegmentedToggleProps<T extends string> {
12
13
  options: SegmentedToggleOption<T>[]
13
14
  value: T
14
15
  onChange: (value: T) => void
15
- accentColor?: 'blue' | 'green' | 'red' | 'orange' | 'cyan' | 'yellow' | 'purple' | 'indigo' | 'emerald' | 'amber' | 'violet' | 'neutral' | 'sky' | 'pink' | 'teal'
16
+ accentColor?: AccentColor
16
17
  /** Visual style: 'filled' (default) has a container background, 'outline' is transparent */
17
18
  variant?: 'filled' | 'outline'
18
19
  size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
@@ -135,6 +136,8 @@ export function SegmentedToggle<T extends string>({
135
136
  return (
136
137
  <Tooltip key={option.value} content={option.tooltip} position={tooltipPosition}>
137
138
  <button
139
+ aria-pressed={isActive}
140
+ aria-label={option.label || (typeof option.tooltip.description === 'string' ? option.tooltip.description : undefined)}
138
141
  onClick={() => onChange(option.value)}
139
142
  disabled={disabled}
140
143
  className={`flex items-center justify-center ${sizeClass} ${rounding} font-medium transition-all cursor-pointer ${
@@ -1,6 +1,7 @@
1
1
  import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { ChevronDown, Check } from 'lucide-react'
4
+ import { useClickOutside } from '../hooks/use-click-outside.ts'
4
5
  import { useDropdownMaxHeight } from '../hooks/use-dropdown-max-height.ts'
5
6
  import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
6
7
 
@@ -74,17 +75,7 @@ export function Select<T extends string | number = string>({
74
75
  }, [])
75
76
 
76
77
  // Close on click outside both the trigger and the portal menu
77
- useEffect(() => {
78
- if (!isOpen) return
79
- const handleClick = (event: MouseEvent) => {
80
- const target = event.target as Node
81
- if (ref.current?.contains(target)) return
82
- if (menuRef.current?.contains(target)) return
83
- close()
84
- }
85
- document.addEventListener('mousedown', handleClick)
86
- return () => document.removeEventListener('mousedown', handleClick)
87
- }, [isOpen, close])
78
+ useClickOutside([ref, menuRef], isOpen, close)
88
79
 
89
80
  useEffect(() => {
90
81
  if (highlightIdx >= 0 && menuRef.current) {
@@ -119,6 +110,8 @@ export function Select<T extends string | number = string>({
119
110
  <button
120
111
  ref={buttonRef}
121
112
  type="button"
113
+ aria-expanded={isOpen}
114
+ aria-haspopup="listbox"
122
115
  onClick={() => !disabled && (isOpen ? close() : open())}
123
116
  disabled={disabled}
124
117
  className={`flex items-center gap-1.5 min-w-0 rounded-lg border ${v.bg} ${FORM_COLORS[color].border} text-neutral-200 focus:outline-none ${FORM_COLORS[color].focus} transition-colors ${
@@ -126,7 +119,7 @@ export function Select<T extends string | number = string>({
126
119
  } ${s}`}
127
120
  >
128
121
  {selectedOption?.icon}
129
- <span className={`whitespace-nowrap ${selectedOption ? '' : 'text-neutral-500'}`}>
122
+ <span className={`truncate ${selectedOption ? '' : 'text-neutral-500'}`}>
130
123
  {selectedOption?.label ?? placeholder}
131
124
  </span>
132
125
  <ChevronDown className={`w-3 h-3 ml-auto text-neutral-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
@@ -134,7 +127,8 @@ export function Select<T extends string | number = string>({
134
127
  {isOpen && menuPos && createPortal(
135
128
  <div
136
129
  ref={menuRef}
137
- className={`fixed z-[9999] whitespace-nowrap ${v.menuBg} border ${FORM_COLORS[color].border} rounded-lg shadow-xl overflow-hidden`}
130
+ role="listbox"
131
+ className={`fixed z-[9999] whitespace-nowrap ${v.menuBg} border ${FORM_COLORS[color].border} rounded-lg shadow-lg overflow-hidden`}
138
132
  style={{
139
133
  top: menuPos.top,
140
134
  left: align === 'right' ? undefined : menuPos.left,
@@ -160,7 +154,7 @@ export function Select<T extends string | number = string>({
160
154
  >
161
155
  <Check className={`w-3 h-3 shrink-0 ${isSelected ? FORM_COLORS[color].accent : 'invisible'}`} />
162
156
  {opt.icon}
163
- <span>{opt.label}</span>
157
+ <span className="truncate">{opt.label}</span>
164
158
  </button>
165
159
  )
166
160
  })}
@@ -1,54 +1,8 @@
1
- import {
2
- Check, Settings, Code, Folder, File, Terminal, Globe, Database, Cloud,
3
- Sparkles, Zap, Shield, ShieldCheck, Wand2, Star, Heart, Bell,
4
- Search, Filter, Eye, Lock, User, Users, Image, Tag, Pin, Mail,
5
- Send, Bookmark, Play, Pause, Bot, Plug, Puzzle, Webhook, Scan,
6
- } from 'lucide-react'
7
- import type { LucideIcon } from 'lucide-react'
8
- import type { IconName } from './icon-button.tsx'
1
+ import { iconMap, type IconName } from './icon-button.tsx'
9
2
  import type { ConfirmBadgeColor } from './confirm-badge.tsx'
10
3
  import { cn } from '../lib/cn.ts'
11
4
  import { AiToolIcon, AI_TOOL_NAMES, type AiToolKey } from '../lib/ai-tools.tsx'
12
5
 
13
- const iconMap: Partial<Record<IconName, LucideIcon>> = {
14
- 'check': Check,
15
- 'settings': Settings,
16
- 'code': Code,
17
- 'folder': Folder,
18
- 'file': File,
19
- 'terminal': Terminal,
20
- 'globe': Globe,
21
- 'database': Database,
22
- 'cloud': Cloud,
23
- 'sparkles': Sparkles,
24
- 'zap': Zap,
25
- 'shield': Shield,
26
- 'shield-check': ShieldCheck,
27
- 'wand': Wand2,
28
- 'star': Star,
29
- 'heart': Heart,
30
- 'bell': Bell,
31
- 'search': Search,
32
- 'filter': Filter,
33
- 'eye': Eye,
34
- 'lock': Lock,
35
- 'user': User,
36
- 'users': Users,
37
- 'image': Image,
38
- 'tag': Tag,
39
- 'pin': Pin,
40
- 'mail': Mail,
41
- 'send': Send,
42
- 'bookmark': Bookmark,
43
- 'play': Play,
44
- 'pause': Pause,
45
- 'bot': Bot,
46
- 'plug': Plug,
47
- 'puzzle': Puzzle,
48
- 'webhook': Webhook,
49
- 'scan': Scan,
50
- }
51
-
52
6
  /* ── Preset logos (shared AiToolIcon) ─────────────────────── */
53
7
 
54
8
  type IconProps = { className?: string; style?: React.CSSProperties }
@@ -142,9 +96,7 @@ function resolveColor(color?: string): string {
142
96
  }
143
97
 
144
98
  function autoColumns(itemCount: number): number {
145
- if (itemCount <= 2) return itemCount
146
- if (itemCount <= 4) return itemCount
147
- return 5
99
+ return Math.min(itemCount, 5)
148
100
  }
149
101
 
150
102
  /* ── Component ─────────────────────────────────────────────── */
@@ -228,6 +180,8 @@ function GridCard({ item, selected, onClick }: CardProps) {
228
180
  return (
229
181
  <button
230
182
  type="button"
183
+ aria-pressed={selected}
184
+ aria-label={item.name}
231
185
  onClick={onClick}
232
186
  disabled={item.disabled}
233
187
  className={cn(
@@ -267,6 +221,8 @@ function ListCard({ item, selected, onClick }: CardProps) {
267
221
  return (
268
222
  <button
269
223
  type="button"
224
+ aria-pressed={selected}
225
+ aria-label={item.name}
270
226
  onClick={onClick}
271
227
  disabled={item.disabled}
272
228
  className={cn(
@@ -11,9 +11,10 @@
11
11
  * - input: renders a text Input
12
12
  */
13
13
 
14
- import { Toggle, type ToggleColor, type ToggleSize, type ToggleVariant } from './toggle.tsx'
14
+ import { Toggle, type ToggleColor, type ToggleSize } from './toggle.tsx'
15
15
  import { Select, type SelectOption } from './select.tsx'
16
16
  import { Input } from './input.tsx'
17
+ import { cn } from '../lib/cn.ts'
17
18
 
18
19
  interface SettingRowBase {
19
20
  label: string
@@ -28,7 +29,6 @@ interface SettingRowToggle extends SettingRowBase {
28
29
  onChange: (checked: boolean) => void
29
30
  color?: ToggleColor
30
31
  size?: ToggleSize
31
- variant?: ToggleVariant
32
32
  }
33
33
 
34
34
  interface SettingRowSelect extends SettingRowBase {
@@ -52,10 +52,10 @@ interface SettingRowInput extends SettingRowBase {
52
52
  export type SettingRowProps = SettingRowToggle | SettingRowSelect | SettingRowInput
53
53
 
54
54
  export function SettingRow(props: SettingRowProps) {
55
- const { label, description, disabled, className = '' } = props
55
+ const { label, description, disabled, className } = props
56
56
 
57
57
  return (
58
- <div className={`flex items-start justify-between gap-4 ${className}`}>
58
+ <div className={cn('flex items-start justify-between gap-4', className)}>
59
59
  <div>
60
60
  <label className="text-neutral-200 leading-7">{label}</label>
61
61
  {description && <p className="text-md text-neutral-500">{description}</p>}
@@ -67,7 +67,7 @@ export function SettingRow(props: SettingRowProps) {
67
67
  disabled={disabled}
68
68
  color={props.color}
69
69
  size={props.size}
70
- variant={props.variant}
70
+ aria-label={label}
71
71
  />
72
72
  )}
73
73
  {props.type === 'select' && (
@@ -16,8 +16,8 @@ export function SettingsCard({ children, className, title, description, testId }
16
16
  >
17
17
  {title && (
18
18
  <div>
19
- <h3 className="text-md font-medium text-neutral-200">{title}</h3>
20
- {description && <p className="text-md text-neutral-500 mt-1">{description}</p>}
19
+ <h3 className="text-md font-medium text-neutral-200 truncate">{title}</h3>
20
+ {description && <p className="text-md text-neutral-500 mt-1 line-clamp-2">{description}</p>}
21
21
  </div>
22
22
  )}
23
23
  {!title && description && <p className="text-md text-neutral-500">{description}</p>}
@@ -1,7 +1,8 @@
1
1
  import { Info, AlertTriangle, CheckCircle, AlertCircle } from 'lucide-react'
2
+ import { ACCENT_TEXT, type AccentColor } from '../lib/form-colors.ts'
2
3
  import { cn } from '../lib/cn.ts'
3
4
 
4
- export type SettingsInfoBoxColor = 'neutral' | 'blue' | 'amber' | 'green' | 'red' | 'orange' | 'cyan' | 'yellow' | 'purple' | 'indigo' | 'emerald' | 'violet' | 'sky' | 'pink' | 'teal'
5
+ export type SettingsInfoBoxColor = AccentColor
5
6
 
6
7
  export interface SettingsInfoBoxProps {
7
8
  children: React.ReactNode
@@ -10,7 +11,7 @@ export interface SettingsInfoBoxProps {
10
11
  testId?: string
11
12
  }
12
13
 
13
- const iconMap: Record<SettingsInfoBoxColor, typeof Info> = {
14
+ const INFO_BOX_ICONS: Record<SettingsInfoBoxColor, typeof Info> = {
14
15
  neutral: Info,
15
16
  blue: Info,
16
17
  amber: AlertTriangle,
@@ -28,24 +29,6 @@ const iconMap: Record<SettingsInfoBoxColor, typeof Info> = {
28
29
  teal: Info,
29
30
  }
30
31
 
31
- const iconColorMap: Record<SettingsInfoBoxColor, string> = {
32
- neutral: 'text-neutral-500',
33
- blue: 'text-blue-400',
34
- amber: 'text-amber-400',
35
- green: 'text-green-400',
36
- red: 'text-red-400',
37
- orange: 'text-orange-400',
38
- cyan: 'text-cyan-400',
39
- yellow: 'text-yellow-400',
40
- purple: 'text-purple-400',
41
- indigo: 'text-indigo-400',
42
- emerald: 'text-emerald-400',
43
- violet: 'text-violet-400',
44
- sky: 'text-sky-400',
45
- pink: 'text-pink-400',
46
- teal: 'text-teal-400',
47
- }
48
-
49
32
  const borderColorMap: Record<SettingsInfoBoxColor, string> = {
50
33
  neutral: 'border-l-neutral-600',
51
34
  blue: 'border-l-blue-500',
@@ -65,15 +48,14 @@ const borderColorMap: Record<SettingsInfoBoxColor, string> = {
65
48
  }
66
49
 
67
50
  export function SettingsInfoBox({ children, color = 'neutral', className, testId }: SettingsInfoBoxProps) {
68
- const Icon = iconMap[color]
51
+ const Icon = INFO_BOX_ICONS[color]
69
52
 
70
53
  return (
71
54
  <div
72
- className={cn('flex items-start gap-3 border-l-2', borderColorMap[color], className)}
73
- style={{ paddingLeft: 10 }}
55
+ className={cn('flex items-start gap-3 border-l-2 pl-2.5', borderColorMap[color], className)}
74
56
  data-testid={testId}
75
57
  >
76
- <Icon className={cn('w-4 h-4 mt-0.5 shrink-0', iconColorMap[color])} />
58
+ <Icon className={cn('w-4 h-4 mt-0.5 shrink-0', ACCENT_TEXT[color])} />
77
59
  <div className="text-md text-neutral-500">{children}</div>
78
60
  </div>
79
61
  )