@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,133 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Tooltip, type TooltipContent, type TooltipPosition } from './tooltip.tsx'
3
+
4
+ export interface SegmentedToggleOption<T extends string> {
5
+ value: T
6
+ icon: ReactNode
7
+ tooltip: TooltipContent
8
+ }
9
+
10
+ export interface SegmentedToggleProps<T extends string> {
11
+ options: SegmentedToggleOption<T>[]
12
+ value: T
13
+ onChange: (value: T) => void
14
+ accentColor?: 'blue' | 'purple' | 'orange' | 'green' | 'pink' | 'amber' | 'emerald' | 'teal' | 'sky'
15
+ /** Visual style: 'filled' (default) has a container background, 'outline' is transparent */
16
+ variant?: 'filled' | 'outline'
17
+ size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
18
+ tooltipPosition?: TooltipPosition
19
+ testId?: string
20
+ disabled?: boolean
21
+ /** Tooltip shown on the entire toggle when disabled */
22
+ disabledTooltip?: TooltipContent
23
+ }
24
+
25
+ /** Button sizes match IconButton dimensions */
26
+ const BUTTON_SIZE_CLASSES = {
27
+ xss: 'w-[18px] h-[18px]',
28
+ xs: 'w-6 h-6',
29
+ sm: 'w-7 h-7',
30
+ md: 'w-8 h-8',
31
+ lg: 'w-9 h-9',
32
+ }
33
+
34
+ const ROUNDING_CLASSES = {
35
+ xss: 'rounded-[3px]',
36
+ xs: 'rounded-[5px]',
37
+ sm: 'rounded-md',
38
+ md: 'rounded-md',
39
+ lg: 'rounded-md',
40
+ }
41
+
42
+ const ACTIVE_COLORS: Record<string, string> = {
43
+ blue: 'bg-blue-500/20 text-blue-300 border-blue-500/40',
44
+ purple: 'bg-purple-500/20 text-purple-300 border-purple-500/40',
45
+ orange: 'bg-orange-500/20 text-orange-300 border-orange-500/40',
46
+ green: 'bg-green-500/20 text-green-300 border-green-500/40',
47
+ pink: 'bg-pink-500/20 text-pink-300 border-pink-500/40',
48
+ amber: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
49
+ emerald: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
50
+ teal: 'bg-teal-500/20 text-teal-300 border-teal-500/40',
51
+ sky: 'bg-sky-500/20 text-sky-300 border-sky-500/40',
52
+ }
53
+
54
+ const HOVER_COLORS: Record<string, string> = {
55
+ blue: 'hover:bg-blue-500/15 hover:text-blue-300',
56
+ purple: 'hover:bg-purple-500/15 hover:text-purple-300',
57
+ orange: 'hover:bg-orange-500/15 hover:text-orange-300',
58
+ green: 'hover:bg-green-500/15 hover:text-green-300',
59
+ pink: 'hover:bg-pink-500/15 hover:text-pink-300',
60
+ amber: 'hover:bg-amber-500/15 hover:text-amber-300',
61
+ emerald: 'hover:bg-emerald-500/15 hover:text-emerald-300',
62
+ teal: 'hover:bg-teal-500/15 hover:text-teal-300',
63
+ sky: 'hover:bg-sky-500/15 hover:text-sky-300',
64
+ }
65
+
66
+ const OUTLINE_CONTAINER: Record<string, string> = {
67
+ blue: 'flex items-center border border-blue-500/50 rounded-md',
68
+ purple: 'flex items-center border border-purple-500/50 rounded-md',
69
+ orange: 'flex items-center border border-orange-500/50 rounded-md',
70
+ green: 'flex items-center border border-green-500/50 rounded-md',
71
+ pink: 'flex items-center border border-pink-500/50 rounded-md',
72
+ amber: 'flex items-center border border-amber-500/50 rounded-md',
73
+ emerald: 'flex items-center border border-emerald-500/50 rounded-md',
74
+ teal: 'flex items-center border border-teal-500/50 rounded-md',
75
+ sky: 'flex items-center border border-sky-500/50 rounded-md',
76
+ }
77
+
78
+ export function SegmentedToggle<T extends string>({
79
+ options,
80
+ value,
81
+ onChange,
82
+ accentColor = 'blue',
83
+ variant = 'outline',
84
+ size = 'sm',
85
+ tooltipPosition = 'bottom',
86
+ testId,
87
+ disabled = false,
88
+ disabledTooltip,
89
+ }: SegmentedToggleProps<T>) {
90
+ const isOutline = variant === 'outline'
91
+ const containerClasses = isOutline
92
+ ? OUTLINE_CONTAINER[accentColor] || OUTLINE_CONTAINER.blue
93
+ : 'flex items-center bg-neutral-800/50 border border-neutral-700 rounded-md'
94
+
95
+ const toggle = (
96
+ <div
97
+ className={`${containerClasses} ${disabled ? 'opacity-40 pointer-events-none' : ''}`}
98
+ data-testid={testId}
99
+ >
100
+ {options.map((option, i) => {
101
+ const isActive = value === option.value
102
+ const isFirst = i === 0
103
+ const isLast = i === options.length - 1
104
+ const rounding = isFirst && isLast ? ROUNDING_CLASSES[size] : isFirst ? `rounded-l-[5px]` : isLast ? `rounded-r-[5px]` : ''
105
+ return (
106
+ <Tooltip key={option.value} content={option.tooltip} position={tooltipPosition}>
107
+ <button
108
+ onClick={() => onChange(option.value)}
109
+ disabled={disabled}
110
+ className={`flex items-center justify-center ${BUTTON_SIZE_CLASSES[size]} ${rounding} text-xs font-medium transition-all cursor-pointer ${
111
+ isActive
112
+ ? ACTIVE_COLORS[accentColor] || ACTIVE_COLORS.blue
113
+ : `text-neutral-400 ${HOVER_COLORS[accentColor] || HOVER_COLORS.blue}`
114
+ }`}
115
+ >
116
+ {option.icon}
117
+ </button>
118
+ </Tooltip>
119
+ )
120
+ })}
121
+ </div>
122
+ )
123
+
124
+ if (disabled && disabledTooltip) {
125
+ return (
126
+ <Tooltip content={disabledTooltip} position={tooltipPosition}>
127
+ <div className="pointer-events-auto">{toggle}</div>
128
+ </Tooltip>
129
+ )
130
+ }
131
+
132
+ return toggle
133
+ }
@@ -0,0 +1,172 @@
1
+ import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { ChevronDown, Check } from 'lucide-react'
4
+ import { useDropdownMaxHeight } from '../hooks/use-dropdown-max-height.ts'
5
+ import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
6
+
7
+ export interface SelectOption<T extends string | number = string> {
8
+ value: T
9
+ label: string
10
+ icon?: ReactNode
11
+ }
12
+
13
+ export interface SelectProps<T extends string | number = string> {
14
+ value: T
15
+ options: SelectOption<T>[]
16
+ onChange: (value: T) => void
17
+ placeholder?: string
18
+ variant?: 'filled' | 'outline'
19
+ color?: FormColor
20
+ size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
21
+ align?: 'left' | 'right'
22
+ disabled?: boolean
23
+ className?: string
24
+ }
25
+
26
+ const VARIANT_CLASSES = {
27
+ filled: { bg: 'bg-neutral-800', hoverBg: 'hover:bg-neutral-700', menuBg: 'bg-neutral-800' },
28
+ outline: { bg: 'bg-transparent', hoverBg: 'hover:bg-neutral-800', menuBg: 'bg-neutral-800/90 backdrop-blur-sm' },
29
+ }
30
+
31
+ const SIZE_CLASSES = {
32
+ xss: 'h-[18px] px-1.5 text-[10px]',
33
+ xs: 'h-6 px-2 text-xs',
34
+ sm: 'h-7 px-2 text-xs',
35
+ md: 'h-8 px-3 text-sm',
36
+ lg: 'h-9 px-3 text-sm',
37
+ }
38
+
39
+ export function Select<T extends string | number = string>({
40
+ value,
41
+ options,
42
+ onChange,
43
+ placeholder = 'Select...',
44
+ variant = 'outline',
45
+ color = 'blue',
46
+ size = 'sm',
47
+ align = 'left',
48
+ disabled = false,
49
+ className = '',
50
+ }: SelectProps<T>) {
51
+ const [isOpen, setIsOpen] = useState(false)
52
+ const [highlightIdx, setHighlightIdx] = useState(-1)
53
+ const ref = useRef<HTMLDivElement>(null)
54
+ const buttonRef = useRef<HTMLButtonElement>(null)
55
+ const menuRef = useDropdownMaxHeight<HTMLDivElement>(isOpen)
56
+ const [menuPos, setMenuPos] = useState<{ top: number; left: number; minWidth: number } | null>(null)
57
+
58
+ const updateMenuPos = useCallback(() => {
59
+ if (!buttonRef.current) return
60
+ const rect = buttonRef.current.getBoundingClientRect()
61
+ const left = align === 'right' ? rect.right : rect.left
62
+ setMenuPos({ top: rect.bottom + 4, left, minWidth: Math.max(rect.width, 140) })
63
+ }, [align])
64
+
65
+ const open = useCallback(() => {
66
+ setHighlightIdx(options.findIndex((o) => o.value === value))
67
+ updateMenuPos()
68
+ setIsOpen(true)
69
+ }, [options, value, updateMenuPos])
70
+
71
+ const close = useCallback(() => {
72
+ setHighlightIdx(-1)
73
+ setIsOpen(false)
74
+ }, [])
75
+
76
+ // 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])
88
+
89
+ useEffect(() => {
90
+ if (highlightIdx >= 0 && menuRef.current) {
91
+ menuRef.current.querySelector<HTMLElement>(`[data-idx="${highlightIdx}"]`)?.scrollIntoView({ block: 'nearest' })
92
+ }
93
+ }, [highlightIdx, menuRef])
94
+
95
+ const v = VARIANT_CLASSES[variant]
96
+ const s = SIZE_CLASSES[size]
97
+ const selectedOption = options.find((o) => o.value === value)
98
+
99
+ const handleKeyDown = (e: React.KeyboardEvent) => {
100
+ if (!isOpen) return
101
+ if (e.key === 'ArrowDown') {
102
+ e.preventDefault()
103
+ setHighlightIdx((i) => Math.min(i + 1, options.length - 1))
104
+ } else if (e.key === 'ArrowUp') {
105
+ e.preventDefault()
106
+ setHighlightIdx((i) => Math.max(i - 1, 0))
107
+ } else if (e.key === 'Enter' && highlightIdx >= 0) {
108
+ e.preventDefault()
109
+ onChange(options[highlightIdx].value)
110
+ close()
111
+ } else if (e.key === 'Escape') {
112
+ e.preventDefault()
113
+ close()
114
+ }
115
+ }
116
+
117
+ return (
118
+ <div className={`inline-flex ${className}`} ref={ref} onKeyDown={handleKeyDown}>
119
+ <button
120
+ ref={buttonRef}
121
+ type="button"
122
+ onClick={() => !disabled && (isOpen ? close() : open())}
123
+ disabled={disabled}
124
+ 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 ${
125
+ disabled ? 'opacity-50 cursor-not-allowed' : `cursor-pointer ${FORM_COLORS[color].hover}`
126
+ } ${s}`}
127
+ >
128
+ {selectedOption?.icon}
129
+ <span className={`whitespace-nowrap ${selectedOption ? '' : 'text-neutral-500'}`}>
130
+ {selectedOption?.label ?? placeholder}
131
+ </span>
132
+ <ChevronDown className={`w-3 h-3 ml-auto text-neutral-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
133
+ </button>
134
+ {isOpen && menuPos && createPortal(
135
+ <div
136
+ ref={menuRef}
137
+ className={`fixed z-[9999] whitespace-nowrap ${v.menuBg} border ${FORM_COLORS[color].border} rounded-lg shadow-xl overflow-hidden`}
138
+ style={{
139
+ top: menuPos.top,
140
+ left: align === 'right' ? undefined : menuPos.left,
141
+ right: align === 'right' ? window.innerWidth - menuPos.left : undefined,
142
+ minWidth: menuPos.minWidth,
143
+ }}
144
+ >
145
+ {options.map((opt, idx) => {
146
+ const isHighlighted = highlightIdx === idx
147
+ const isSelected = value === opt.value
148
+ return (
149
+ <button
150
+ key={String(opt.value)}
151
+ type="button"
152
+ data-idx={idx}
153
+ onClick={() => { onChange(opt.value); close() }}
154
+ onPointerEnter={() => setHighlightIdx(idx)}
155
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer ${
156
+ isHighlighted
157
+ ? 'bg-neutral-600 text-neutral-200'
158
+ : isSelected ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
159
+ }`}
160
+ >
161
+ <Check className={`w-3 h-3 shrink-0 ${isSelected ? FORM_COLORS[color].accent : 'invisible'}`} />
162
+ {opt.icon}
163
+ <span>{opt.label}</span>
164
+ </button>
165
+ )
166
+ })}
167
+ </div>,
168
+ document.body,
169
+ )}
170
+ </div>
171
+ )
172
+ }
@@ -0,0 +1,313 @@
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'
9
+ import { ConfirmBadge, type ConfirmBadgeColor } from './confirm-badge.tsx'
10
+ import { cn } from '../lib/cn.ts'
11
+ import { AiToolIcon, AI_TOOL_NAMES, type AiToolKey } from '../lib/ai-tools.tsx'
12
+
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
+ /* ── Preset logos (shared AiToolIcon) ─────────────────────── */
53
+
54
+ type IconProps = { className?: string; style?: React.CSSProperties }
55
+
56
+ function makeToolLogo(toolKey: AiToolKey): React.ComponentType<IconProps> {
57
+ function ToolLogo({ className, style }: IconProps) {
58
+ return <AiToolIcon tool={toolKey} className={className} style={style} />
59
+ }
60
+ return ToolLogo
61
+ }
62
+
63
+ /* ── Preset types & data ──────────────────────────────────── */
64
+
65
+ export type CodingToolId = 'claude-code' | 'github-copilot' | 'codex-cli' | 'gemini-cli' | 'opencode'
66
+
67
+ export type CodingToolPresetConfig = CodingToolId | {
68
+ id: CodingToolId
69
+ description?: string
70
+ disabled?: boolean
71
+ }
72
+
73
+ const CODING_TOOL_PRESETS: Record<CodingToolId, { name: string; description: string; color: string; badgeColor: ConfirmBadgeColor; Logo: React.ComponentType<IconProps> }> = {
74
+ 'claude-code': { name: AI_TOOL_NAMES.claude, description: 'Security & architecture focus', color: '#a78bfa', badgeColor: 'violet', Logo: makeToolLogo('claude') },
75
+ 'github-copilot': { name: AI_TOOL_NAMES.copilot, description: 'Code quality & patterns', color: '#f97316', badgeColor: 'orange', Logo: makeToolLogo('copilot') },
76
+ 'codex-cli': { name: AI_TOOL_NAMES.codex, description: 'Performance & optimization', color: '#60a5fa', badgeColor: 'blue', Logo: makeToolLogo('codex') },
77
+ 'gemini-cli': { name: AI_TOOL_NAMES.gemini, description: 'Best practices & docs', color: '#22d3ee', badgeColor: 'cyan', Logo: makeToolLogo('gemini') },
78
+ 'opencode': { name: AI_TOOL_NAMES.opencode, description: 'Provider-agnostic AI coding agent', color: '#a1a1aa', badgeColor: 'neutral', Logo: makeToolLogo('opencode') },
79
+ }
80
+
81
+ function resolvePresets(presets: CodingToolPresetConfig[]): SelectionCardItem[] {
82
+ const result: SelectionCardItem[] = []
83
+ for (const p of presets) {
84
+ const id = typeof p === 'string' ? p : p.id
85
+ const data = CODING_TOOL_PRESETS[id]
86
+ if (!data) continue
87
+ const item: SelectionCardItem = {
88
+ id,
89
+ IconComponent: data.Logo,
90
+ name: data.name,
91
+ description: data.description,
92
+ color: data.color,
93
+ badgeColor: data.badgeColor,
94
+ }
95
+ if (typeof p === 'object') {
96
+ if (p.description !== undefined) item.description = p.description
97
+ if (p.disabled !== undefined) item.disabled = p.disabled
98
+ }
99
+ result.push(item)
100
+ }
101
+ return result
102
+ }
103
+
104
+ /* ── Public types ─────────────────────────────────────────── */
105
+
106
+ export interface SelectionCardItem {
107
+ id: string
108
+ icon?: IconName
109
+ /** Custom SVG icon component. Receives className and style. Takes precedence over `icon`. */
110
+ IconComponent?: React.ComponentType<IconProps>
111
+ name: string
112
+ description?: string
113
+ /** Accent color for selected state border/bg. CSS color string like '#a78bfa' */
114
+ color?: string
115
+ /** Named color for the confirm badge. Defaults to 'blue'. */
116
+ badgeColor?: ConfirmBadgeColor
117
+ disabled?: boolean
118
+ }
119
+
120
+ export interface SelectionGridProps {
121
+ /** Custom items with manual icon/name/color configuration */
122
+ items?: SelectionCardItem[]
123
+ /** Built-in coding tool presets. Pass tool IDs or config objects. Ignored when `items` is provided. */
124
+ presets?: CodingToolPresetConfig[]
125
+ selectedIds: string[]
126
+ onSelect: (ids: string[]) => void
127
+ /** Single selection or multiple */
128
+ mode?: 'single' | 'multiple'
129
+ /** Grid (icon-top cards) or list (icon-left rows) */
130
+ layout?: 'grid' | 'list'
131
+ /** Number of columns for grid layout (auto if not set) */
132
+ columns?: number
133
+ className?: string
134
+ }
135
+
136
+ /* ── Helpers ───────────────────────────────────────────────── */
137
+
138
+ const DEFAULT_COLOR = 'blue-400'
139
+
140
+ function resolveColor(color?: string): string {
141
+ return color || DEFAULT_COLOR
142
+ }
143
+
144
+ function autoColumns(itemCount: number): number {
145
+ if (itemCount <= 2) return itemCount
146
+ if (itemCount <= 4) return itemCount
147
+ return 5
148
+ }
149
+
150
+ /* ── Component ─────────────────────────────────────────────── */
151
+
152
+ export function SelectionGrid({
153
+ items: itemsProp,
154
+ presets,
155
+ selectedIds,
156
+ onSelect,
157
+ mode = 'multiple',
158
+ layout = 'grid',
159
+ columns,
160
+ className,
161
+ }: SelectionGridProps) {
162
+ const items = itemsProp || (presets ? resolvePresets(presets) : [])
163
+
164
+ function handleClick(item: SelectionCardItem) {
165
+ if (item.disabled) return
166
+
167
+ if (mode === 'single') {
168
+ onSelect([item.id])
169
+ return
170
+ }
171
+
172
+ if (selectedIds.includes(item.id)) {
173
+ onSelect(selectedIds.filter((id) => id !== item.id))
174
+ } else {
175
+ onSelect([...selectedIds, item.id])
176
+ }
177
+ }
178
+
179
+ const cols = columns || autoColumns(items.length)
180
+
181
+ if (layout === 'list') {
182
+ return (
183
+ <div
184
+ className={cn('grid gap-3', className)}
185
+ style={{ gridTemplateColumns: `repeat(${columns || 1}, 1fr)` }}
186
+ >
187
+ {items.map((item) => (
188
+ <ListCard
189
+ key={item.id}
190
+ item={item}
191
+ selected={selectedIds.includes(item.id)}
192
+ onClick={() => handleClick(item)}
193
+ />
194
+ ))}
195
+ </div>
196
+ )
197
+ }
198
+
199
+ return (
200
+ <div
201
+ className={cn('grid gap-3', className)}
202
+ style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
203
+ >
204
+ {items.map((item) => (
205
+ <GridCard
206
+ key={item.id}
207
+ item={item}
208
+ selected={selectedIds.includes(item.id)}
209
+ onClick={() => handleClick(item)}
210
+ />
211
+ ))}
212
+ </div>
213
+ )
214
+ }
215
+
216
+ /* ── Card subcomponents ────────────────────────────────────── */
217
+
218
+ interface CardProps {
219
+ item: SelectionCardItem
220
+ selected: boolean
221
+ onClick: () => void
222
+ }
223
+
224
+ function GridCard({ item, selected, onClick }: CardProps) {
225
+ const color = resolveColor(item.color)
226
+ const Icon = item.IconComponent || (item.icon ? iconMap[item.icon] : undefined)
227
+
228
+ return (
229
+ <button
230
+ type="button"
231
+ onClick={onClick}
232
+ disabled={item.disabled}
233
+ className={cn(
234
+ 'relative p-3 rounded-lg text-center transition-all border-2 flex flex-col items-center',
235
+ item.disabled
236
+ ? 'opacity-30 cursor-not-allowed border-neutral-800 bg-black'
237
+ : selected
238
+ ? 'cursor-pointer'
239
+ : 'bg-neutral-800/50 border-neutral-700 hover:bg-neutral-800 hover:border-neutral-600 cursor-pointer',
240
+ )}
241
+ style={
242
+ selected && !item.disabled
243
+ ? { borderColor: color, backgroundColor: `${color}15` }
244
+ : undefined
245
+ }
246
+ >
247
+ {selected && !item.disabled && (
248
+ <span className="absolute top-1 right-1">
249
+ <ConfirmBadge color={item.badgeColor || 'blue'} size="xs" />
250
+ </span>
251
+ )}
252
+
253
+ {Icon && (
254
+ <Icon
255
+ className="w-7 h-7 mb-1"
256
+ style={{ color: item.disabled ? undefined : (item.color || '#9ca3af') }}
257
+ />
258
+ )}
259
+
260
+ <span className="text-xs font-medium text-neutral-200 block">{item.name}</span>
261
+
262
+ {item.description && (
263
+ <span className="text-xs text-neutral-500 block mt-0.5">{item.description}</span>
264
+ )}
265
+ </button>
266
+ )
267
+ }
268
+
269
+ function ListCard({ item, selected, onClick }: CardProps) {
270
+ const color = resolveColor(item.color)
271
+ const Icon = item.IconComponent || (item.icon ? iconMap[item.icon] : undefined)
272
+
273
+ return (
274
+ <button
275
+ type="button"
276
+ onClick={onClick}
277
+ disabled={item.disabled}
278
+ className={cn(
279
+ 'relative p-3 rounded-lg transition-all border-2 flex items-center gap-3 text-left',
280
+ item.disabled
281
+ ? 'opacity-30 cursor-not-allowed border-neutral-800 bg-black'
282
+ : selected
283
+ ? 'cursor-pointer'
284
+ : 'bg-neutral-800/50 border-neutral-700 hover:bg-neutral-800 hover:border-neutral-600 cursor-pointer',
285
+ )}
286
+ style={
287
+ selected && !item.disabled
288
+ ? { borderColor: color, backgroundColor: `${color}15` }
289
+ : undefined
290
+ }
291
+ >
292
+ {selected && !item.disabled && (
293
+ <span className="absolute top-1 right-1">
294
+ <ConfirmBadge color={item.badgeColor || 'blue'} size="xs" />
295
+ </span>
296
+ )}
297
+
298
+ {Icon && (
299
+ <Icon
300
+ className="w-5 h-5 flex-shrink-0"
301
+ style={{ color: item.disabled ? undefined : (item.color || '#9ca3af') }}
302
+ />
303
+ )}
304
+
305
+ <div className="min-w-0">
306
+ <span className="text-xs font-medium text-neutral-200 block">{item.name}</span>
307
+ {item.description && (
308
+ <span className="text-xs text-neutral-500 block mt-0.5">{item.description}</span>
309
+ )}
310
+ </div>
311
+ </button>
312
+ )
313
+ }