@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,710 @@
1
+ import { type ReactNode, type MouseEvent, useState } from 'react'
2
+ import {
3
+ Clock, Download, FolderPlus, Loader2, Plus, Power, Star, Trash2,
4
+ Sparkles, Bot, Webhook, Terminal, Plug, Settings, Package, Puzzle,
5
+ } from 'lucide-react'
6
+ import { Tooltip } from './tooltip.tsx'
7
+ import { IconButton, type IconName } from './icon-button.tsx'
8
+ import { Label, type LabelColor } from './label.tsx'
9
+ import { AiToolIcon, AI_TOOL_NAMES, type AiToolKey } from '../lib/ai-tools.tsx'
10
+ export { AiToolIcon, AI_TOOL_NAMES, type AiToolKey }
11
+
12
+ // ── Constants ─────────────────────────────────────────────────────────────────
13
+
14
+ export type RegistryItemType = 'skill' | 'hook' | 'agent' | 'command' | 'plugin' | 'mcp' | 'settings'
15
+ export type ExtensionSource = 'project' | 'plugin' | 'user' | 'local'
16
+
17
+ const TYPE_ICONS: Record<string, typeof Sparkles> = {
18
+ skill: Sparkles,
19
+ command: Terminal,
20
+ hook: Webhook,
21
+ agent: Bot,
22
+ mcp: Plug,
23
+ settings: Settings,
24
+ plugin: Package,
25
+ extension: Puzzle,
26
+ }
27
+
28
+ const SEEDR_ICON_COLORS: Record<string, string> = {
29
+ skill: 'text-pink-400',
30
+ hook: 'text-purple-400',
31
+ agent: 'text-blue-400',
32
+ command: 'text-amber-400',
33
+ plugin: 'text-indigo-400',
34
+ mcp: 'text-teal-400',
35
+ settings: 'text-orange-400',
36
+ }
37
+
38
+ const SEEDR_TYPE_LABELS: Record<string, string> = {
39
+ skill: 'Skill',
40
+ hook: 'Hook',
41
+ agent: 'Agent',
42
+ command: 'Command',
43
+ plugin: 'Plugin',
44
+ mcp: 'MCP Server',
45
+ settings: 'Settings',
46
+ }
47
+
48
+ const SEEDR_CONTENT_TYPES = [
49
+ { key: 'skills' as const, icon: Sparkles, color: 'text-pink-400', label: 'Skill', labelPlural: 'Skills' },
50
+ { key: 'agents' as const, icon: Bot, color: 'text-blue-400', label: 'Agent', labelPlural: 'Agents' },
51
+ { key: 'hooks' as const, icon: Webhook, color: 'text-purple-400', label: 'Hook', labelPlural: 'Hooks' },
52
+ { key: 'commands' as const, icon: Terminal, color: 'text-amber-400', label: 'Command', labelPlural: 'Commands' },
53
+ { key: 'mcpServers' as const, icon: Plug, color: 'text-teal-400', label: 'MCP Server', labelPlural: 'MCP Servers' },
54
+ ]
55
+
56
+ const DISPLAYABLE_SCOPES: ReadonlySet<string> = new Set(['user', 'project', 'local'])
57
+
58
+ const ALREADY_AT_USER = 'Already installed at user level'
59
+
60
+ // ── Category badge helpers ────────────────────────────────────────────────────
61
+
62
+ const CATEGORY_LABEL_COLORS: LabelColor[] = [
63
+ 'cyan', 'amber', 'violet', 'emerald', 'pink', 'blue',
64
+ 'orange', 'teal', 'red', 'indigo', 'green', 'purple',
65
+ ]
66
+
67
+ function getCategoryLabelColor(category: string): LabelColor {
68
+ let hash = 0
69
+ for (let i = 0; i < category.length; i++) {
70
+ hash = ((hash << 5) - hash + category.charCodeAt(i)) | 0
71
+ }
72
+ return CATEGORY_LABEL_COLORS[Math.abs(hash) % CATEGORY_LABEL_COLORS.length]
73
+ }
74
+
75
+ export function formatCategory(category: string): string {
76
+ return category.replace(/(^|-)(\w)/g, (_, sep, ch) => sep + ch.toUpperCase())
77
+ }
78
+
79
+ // ── Time helpers ──────────────────────────────────────────────────────────────
80
+
81
+ function formatRelativeTime(isoDate: string): string {
82
+ const diffMs = Date.now() - new Date(isoDate).getTime()
83
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
84
+ if (diffDays === 0) return 'today'
85
+ if (diffDays === 1) return '1d ago'
86
+ if (diffDays < 7) return `${diffDays}d ago`
87
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`
88
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`
89
+ return `${Math.floor(diffDays / 365)}y ago`
90
+ }
91
+
92
+ function formatFullDate(isoDate: string): string {
93
+ return new Date(isoDate).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
94
+ }
95
+
96
+ function formatCount(n?: number): string {
97
+ if (n == null) return '0'
98
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
99
+ return String(n)
100
+ }
101
+
102
+ // ── Scope helpers ─────────────────────────────────────────────────────────────
103
+
104
+ function computeSeedrScopes(
105
+ installedScopes: ReadonlySet<ExtensionSource> | undefined,
106
+ isUserScope: boolean,
107
+ ) {
108
+ const isInstalledInViewScope = (() => {
109
+ if (!installedScopes || installedScopes.size === 0) return false
110
+ if (isUserScope) return installedScopes.has('user')
111
+ return installedScopes.has('project') || installedScopes.has('local')
112
+ })()
113
+
114
+ const removeScope: ExtensionSource | null = (() => {
115
+ if (!installedScopes || installedScopes.size === 0) return null
116
+ if (isUserScope && installedScopes.has('user')) return 'user'
117
+ if (!isUserScope) {
118
+ if (installedScopes.has('local')) return 'local'
119
+ if (installedScopes.has('project')) return 'project'
120
+ }
121
+ if (installedScopes.has('user')) return 'user'
122
+ if (installedScopes.has('project')) return 'project'
123
+ if (installedScopes.has('local')) return 'local'
124
+ return null
125
+ })()
126
+
127
+ const displayableScopes = installedScopes
128
+ ? [...installedScopes].filter((s) => DISPLAYABLE_SCOPES.has(s))
129
+ : []
130
+
131
+ const isInstalled = displayableScopes.length > 0
132
+
133
+ return { isInstalled, isInstalledInViewScope, removeScope, displayableScopes }
134
+ }
135
+
136
+ // ── Sub-components ────────────────────────────────────────────────────────────
137
+
138
+ function CardClickable({ children, onClick }: { children: ReactNode; onClick: (e: MouseEvent) => void }) {
139
+ return (
140
+ <div
141
+ role="button"
142
+ className="inline-flex cursor-pointer hover:brightness-125 transition-all"
143
+ onClick={(e) => {
144
+ e.stopPropagation()
145
+ onClick(e)
146
+ }}
147
+ >
148
+ {children}
149
+ </div>
150
+ )
151
+ }
152
+
153
+ function CategoryBadge({ category, onFilter }: { category: string; onFilter?: (category: string) => void }) {
154
+ return (
155
+ <Label
156
+ text={formatCategory(category)}
157
+ color={getCategoryLabelColor(category)}
158
+ icon="tag"
159
+ tooltip={{ description: onFilter ? `${formatCategory(category)} \u00b7 Click to filter` : formatCategory(category) }}
160
+ size="sm"
161
+ onClick={onFilter ? () => onFilter(category) : undefined}
162
+ />
163
+ )
164
+ }
165
+
166
+ // ── Badge row ────────────────────────────────────────────────────────────────
167
+
168
+ const SOURCE_TYPE_BADGES: Record<string, { color: LabelColor; icon: IconName }> = {
169
+ official: { color: 'amber', icon: 'shield-check' },
170
+ toolr: { color: 'cyan', icon: 'sparkles' },
171
+ community: { color: 'violet', icon: 'users' },
172
+ }
173
+
174
+ const PLUGIN_TYPE_BADGES: Record<string, { color: LabelColor; icon: IconName }> = {
175
+ package: { color: 'indigo', icon: 'package' },
176
+ wrapper: { color: 'teal', icon: 'puzzle' },
177
+ integration: { color: 'purple', icon: 'plug' },
178
+ }
179
+
180
+ const SCOPE_ICONS: Record<string, IconName> = { user: 'home', project: 'folder', local: 'lock' }
181
+
182
+ interface RegistryCardBadgeRowProps {
183
+ sourceType?: string
184
+ pluginType?: string
185
+ category?: string
186
+ onFilterCategory?: (category: string) => void
187
+ isInstalled: boolean
188
+ isDisabled?: boolean
189
+ scopes: string[]
190
+ status?: SeedrItemStatus
191
+ }
192
+
193
+ function RegistryCardBadgeRow({ sourceType, pluginType, category, onFilterCategory, isInstalled, isDisabled, scopes, status }: RegistryCardBadgeRowProps) {
194
+ const knownScopes = scopes.filter((s) => SCOPE_ICONS[s])
195
+ const scopeIcons = knownScopes.map((s) => SCOPE_ICONS[s]).filter(Boolean) as IconName[]
196
+
197
+ const hasContent = sourceType || pluginType || category || isInstalled || isDisabled || status?.updateAvailable || status?.modified
198
+ if (!hasContent) return null
199
+
200
+ return (
201
+ <div className="flex items-center gap-1.5 flex-wrap">
202
+ {sourceType && SOURCE_TYPE_BADGES[sourceType] && (
203
+ <Label
204
+ text={sourceType}
205
+ color={SOURCE_TYPE_BADGES[sourceType].color}
206
+ icon={SOURCE_TYPE_BADGES[sourceType].icon}
207
+ tooltip={{ description: `Source: ${sourceType}` }}
208
+ size="sm"
209
+ textTransform="capitalize"
210
+ />
211
+ )}
212
+ {pluginType && PLUGIN_TYPE_BADGES[pluginType] && (
213
+ <Label
214
+ text={pluginType}
215
+ color={PLUGIN_TYPE_BADGES[pluginType].color}
216
+ icon={PLUGIN_TYPE_BADGES[pluginType].icon}
217
+ tooltip={{ description: `Plugin type: ${pluginType}` }}
218
+ size="sm"
219
+ textTransform="capitalize"
220
+ />
221
+ )}
222
+ {category && <CategoryBadge category={category} onFilter={onFilterCategory} />}
223
+ {isInstalled && !isDisabled && (
224
+ <Label
225
+ text="Added"
226
+ color="green"
227
+ icon={scopeIcons.length > 0 ? scopeIcons : 'check'}
228
+ tooltip={{ description: `Installed in: ${scopes.join(', ')}` }}
229
+ size="sm"
230
+ />
231
+ )}
232
+ {isDisabled && (
233
+ <Label text="Disabled" color="red" icon="x" tooltip={{ description: 'Installed but disabled' }} size="sm" />
234
+ )}
235
+ {status?.updateAvailable && (
236
+ <Label text="Update" color="amber" icon="download" tooltip={{ description: 'Update available' }} size="sm" />
237
+ )}
238
+ {status?.modified && (
239
+ <Label text="Modified" color="violet" icon="pencil" tooltip={{ description: 'Locally modified' }} size="sm" />
240
+ )}
241
+ </div>
242
+ )
243
+ }
244
+
245
+ // ── Types ─────────────────────────────────────────────────────────────────────
246
+
247
+ export interface SeedrItemStatus {
248
+ updateAvailable: boolean
249
+ modified: boolean
250
+ }
251
+
252
+ interface RegistryCardBase {
253
+ onClick: () => void
254
+ flash?: 'success' | 'error' | 'warning' | null
255
+ name: string
256
+ type: string
257
+ description?: string
258
+ errorMessage?: string
259
+ onInstall: () => void
260
+ isInstalling?: boolean
261
+ updatedAt?: string
262
+ onDateClick?: () => void
263
+ }
264
+
265
+ interface SeedrVariant extends RegistryCardBase {
266
+ variant: 'seedr'
267
+ author: string
268
+ sourceType: string
269
+ pluginType?: string
270
+ wrapper?: string
271
+ installedScopes?: ReadonlySet<ExtensionSource>
272
+ viewScope?: 'user' | 'project'
273
+ status?: SeedrItemStatus
274
+ compatibility: string[]
275
+ packageCounts?: Record<string, number>
276
+ isDisabled?: boolean
277
+ onActivate?: () => void
278
+ onRemove?: (scope: ExtensionSource) => void
279
+ onFilterSource?: () => void
280
+ onFilterPluginType?: (pluginType: string) => void
281
+ /** Render scope confirmation modal */
282
+ renderScopeConfirm?: (props: { isOpen: boolean; onClose: () => void; onConfirm: () => void; name: string }) => ReactNode
283
+ }
284
+
285
+ interface ClaudePluginsVariant extends RegistryCardBase {
286
+ variant: 'claude-plugins'
287
+ author: string
288
+ category?: string
289
+ stars?: number
290
+ downloads?: number
291
+ installedScopes?: ReadonlySet<ExtensionSource>
292
+ viewScope?: 'user' | 'project'
293
+ onRemove?: () => void
294
+ onSortBy?: (field: string) => void
295
+ onFilterCategory?: (category: string) => void
296
+ /** Tool keys compatible with this item type */
297
+ compatibleTools?: string[]
298
+ /** Render scope confirmation modal */
299
+ renderScopeConfirm?: (props: { isOpen: boolean; onClose: () => void; onConfirm: () => void; name: string }) => ReactNode
300
+ }
301
+
302
+ interface AitmplVariant extends RegistryCardBase {
303
+ variant: 'aitmpl'
304
+ category: string
305
+ installedScopes?: ReadonlySet<ExtensionSource>
306
+ viewScope?: 'user' | 'project'
307
+ onRemove?: () => void
308
+ onFilterCategory?: (category: string) => void
309
+ /** Render scope confirmation modal */
310
+ renderScopeConfirm?: (props: { isOpen: boolean; onClose: () => void; onConfirm: () => void; name: string }) => ReactNode
311
+ }
312
+
313
+ interface SkillsShVariant extends RegistryCardBase {
314
+ variant: 'skills-sh'
315
+ author: string
316
+ installs?: number
317
+ installedScopes?: ReadonlySet<ExtensionSource>
318
+ viewScope?: 'user' | 'project'
319
+ onRemove?: () => void
320
+ /** All tool keys to show in bottom row */
321
+ allToolKeys?: string[]
322
+ /** Render scope confirmation modal */
323
+ renderScopeConfirm?: (props: { isOpen: boolean; onClose: () => void; onConfirm: () => void; name: string }) => ReactNode
324
+ }
325
+
326
+ export type RegistryCardProps = SeedrVariant | ClaudePluginsVariant | AitmplVariant | SkillsShVariant
327
+
328
+ // ── Main Component ────────────────────────────────────────────────────────────
329
+
330
+ export function RegistryCard(props: RegistryCardProps) {
331
+ const { onClick, flash, name, type, description, errorMessage, isInstalling, updatedAt, onDateClick } = props
332
+ const [showScopeConfirm, setShowScopeConfirm] = useState(false)
333
+
334
+ const TypeIcon = TYPE_ICONS[type] || TYPE_ICONS.extension
335
+ const typeIconColor = SEEDR_ICON_COLORS[type] || 'text-neutral-400'
336
+ const typeLabel = SEEDR_TYPE_LABELS[type] || type
337
+
338
+ // ── Variant-specific rendering ──
339
+
340
+ let topLeft: ReactNode = null
341
+ let subtitle: ReactNode = null
342
+ let installButton: ReactNode = null
343
+ let bottomLogos: ReactNode[] = []
344
+ let bottomStats: ReactNode[] = []
345
+ let installedElsewhere = false
346
+
347
+ if (props.variant === 'seedr') {
348
+ const isUserScope = props.viewScope === 'user'
349
+ const { isInstalled, isInstalledInViewScope, removeScope, displayableScopes } = computeSeedrScopes(props.installedScopes, isUserScope)
350
+ installedElsewhere = isInstalled && !isInstalledInViewScope
351
+
352
+ topLeft = (
353
+ <RegistryCardBadgeRow
354
+ sourceType={props.sourceType}
355
+ pluginType={props.pluginType}
356
+ isInstalled={isInstalled}
357
+ isDisabled={props.isDisabled}
358
+ scopes={displayableScopes}
359
+ status={props.status}
360
+ />
361
+ )
362
+
363
+ subtitle = <>by {props.author}</>
364
+
365
+ installButton = props.isDisabled ? (
366
+ <IconButton
367
+ icon={<Power className="w-3.5 h-3.5" />}
368
+ onClick={() => props.onActivate?.()}
369
+ size="sm"
370
+ color="green"
371
+ tooltip={{ description: `Activate ${name}` }}
372
+ />
373
+ ) : isInstalledInViewScope && removeScope ? (
374
+ <IconButton
375
+ icon={<Trash2 className="w-3.5 h-3.5" />}
376
+ onClick={() => props.onRemove?.(removeScope)}
377
+ size="sm"
378
+ color="red"
379
+ tooltip={{ description: `Remove ${name} from ${removeScope}` }}
380
+ />
381
+ ) : installedElsewhere ? (
382
+ <IconButton
383
+ icon={<FolderPlus className="w-3.5 h-3.5" />}
384
+ onClick={() => setShowScopeConfirm(true)}
385
+ size="sm"
386
+ color="blue"
387
+ tooltip={{ description: ALREADY_AT_USER }}
388
+ />
389
+ ) : (
390
+ <IconButton
391
+ icon={<Plus className="w-3.5 h-3.5" />}
392
+ onClick={props.onInstall}
393
+ size="sm"
394
+ color="green"
395
+ tooltip={{ description: `Add ${name}` }}
396
+ />
397
+ )
398
+
399
+ bottomLogos = props.compatibility.filter((t) => t in AI_TOOL_NAMES).map((tool) => (
400
+ <Tooltip key={tool} content={{ description: `Compatible with ${AI_TOOL_NAMES[tool as AiToolKey] ?? tool}` }} position="top">
401
+ <AiToolIcon tool={tool} size={16} />
402
+ </Tooltip>
403
+ ))
404
+
405
+ const counts = props.packageCounts ?? (props.wrapper ? { [props.wrapper]: 1 } : undefined)
406
+ if (counts) {
407
+ bottomStats = SEEDR_CONTENT_TYPES.flatMap(({ key, icon: Icon, color, label, labelPlural }) => {
408
+ const typeKey = key === 'mcpServers' ? 'mcp' : key.replace(/s$/, '')
409
+ const count = counts[typeKey]
410
+ if (!count || count <= 0) return []
411
+ return [(
412
+ <Tooltip key={`pkg-${key}`} content={{ description: `${count} ${count === 1 ? label : labelPlural}` }} position="top">
413
+ <span className="flex items-center gap-0.5">
414
+ <Icon className={`w-3 h-3 ${color}`} />
415
+ <span className="text-[10px] text-neutral-500">{count}</span>
416
+ </span>
417
+ </Tooltip>
418
+ )]
419
+ })
420
+ }
421
+ } else if (props.variant === 'claude-plugins') {
422
+ const isUserScope = props.viewScope === 'user'
423
+ const { isInstalled, isInstalledInViewScope, removeScope, displayableScopes } = computeSeedrScopes(props.installedScopes, isUserScope)
424
+ installedElsewhere = isInstalled && !isInstalledInViewScope
425
+
426
+ topLeft = (
427
+ <RegistryCardBadgeRow
428
+ category={props.category}
429
+ onFilterCategory={props.onFilterCategory}
430
+ isInstalled={isInstalled}
431
+ scopes={displayableScopes}
432
+ />
433
+ )
434
+ subtitle = <>by {props.author}</>
435
+
436
+ installButton = isInstalledInViewScope && removeScope ? (
437
+ <IconButton
438
+ icon={<Trash2 className="w-3.5 h-3.5" />}
439
+ onClick={() => props.onRemove?.()}
440
+ size="sm"
441
+ color="red"
442
+ tooltip={{ description: `Remove ${name} from ${removeScope}` }}
443
+ />
444
+ ) : installedElsewhere ? (
445
+ <IconButton
446
+ icon={<FolderPlus className="w-3.5 h-3.5" />}
447
+ onClick={() => setShowScopeConfirm(true)}
448
+ size="sm"
449
+ color="blue"
450
+ tooltip={{ description: ALREADY_AT_USER }}
451
+ />
452
+ ) : (
453
+ <IconButton
454
+ icon={<Plus className="w-3.5 h-3.5" />}
455
+ onClick={props.onInstall}
456
+ size="sm"
457
+ color="green"
458
+ tooltip={{ description: `Add ${name}` }}
459
+ />
460
+ )
461
+
462
+ const compatibleTools = props.compatibleTools ?? []
463
+ bottomLogos = compatibleTools.map((tool) => (
464
+ <Tooltip key={tool} content={{ description: `Compatible with ${AI_TOOL_NAMES[tool as AiToolKey] ?? tool}` }} position="top">
465
+ <AiToolIcon tool={tool} size={16} />
466
+ </Tooltip>
467
+ ))
468
+ bottomStats = [
469
+ ...(props.stars != null && props.stars > 0 ? [(
470
+ <CardClickable key="stars" onClick={() => props.onSortBy?.('stars')}>
471
+ <Tooltip content={{ description: `${props.stars.toLocaleString()} stars \u00b7 Click to sort` }} position="top">
472
+ <span className="flex items-center gap-1 text-[10px] text-amber-400/80">
473
+ <Star className="w-3 h-3" />
474
+ {formatCount(props.stars)}
475
+ </span>
476
+ </Tooltip>
477
+ </CardClickable>
478
+ )] : []),
479
+ ...(props.downloads != null && props.downloads > 0 ? [(
480
+ <CardClickable key="downloads" onClick={() => props.onSortBy?.('downloads')}>
481
+ <Tooltip content={{ description: `${props.downloads.toLocaleString()} downloads \u00b7 Click to sort` }} position="top">
482
+ <span className="flex items-center gap-1 text-[10px] text-emerald-400/80">
483
+ <Download className="w-3 h-3" />
484
+ {formatCount(props.downloads)}
485
+ </span>
486
+ </Tooltip>
487
+ </CardClickable>
488
+ )] : []),
489
+ ]
490
+ } else if (props.variant === 'aitmpl') {
491
+ const isUserScope = props.viewScope === 'user'
492
+ const { isInstalled, isInstalledInViewScope, removeScope, displayableScopes } = computeSeedrScopes(props.installedScopes, isUserScope)
493
+ installedElsewhere = isInstalled && !isInstalledInViewScope
494
+
495
+ topLeft = (
496
+ <RegistryCardBadgeRow
497
+ category={props.category}
498
+ onFilterCategory={props.onFilterCategory}
499
+ isInstalled={isInstalled}
500
+ scopes={displayableScopes}
501
+ />
502
+ )
503
+
504
+ installButton = isInstalledInViewScope && removeScope ? (
505
+ <IconButton
506
+ icon={<Trash2 className="w-3.5 h-3.5" />}
507
+ onClick={() => props.onRemove?.()}
508
+ size="sm"
509
+ color="red"
510
+ tooltip={{ description: `Remove ${name} from ${removeScope}` }}
511
+ />
512
+ ) : installedElsewhere ? (
513
+ <IconButton
514
+ icon={<FolderPlus className="w-3.5 h-3.5" />}
515
+ onClick={() => setShowScopeConfirm(true)}
516
+ size="sm"
517
+ color="blue"
518
+ tooltip={{ description: ALREADY_AT_USER }}
519
+ />
520
+ ) : (
521
+ <IconButton
522
+ icon={<Plus className="w-3.5 h-3.5" />}
523
+ onClick={props.onInstall}
524
+ size="sm"
525
+ color="green"
526
+ tooltip={{ description: `Add ${name}` }}
527
+ />
528
+ )
529
+
530
+ bottomLogos = [(
531
+ <Tooltip key="claude" content={{ description: 'Compatible with Claude Code' }} position="top">
532
+ <AiToolIcon tool="claude" size={16} />
533
+ </Tooltip>
534
+ )]
535
+ } else if (props.variant === 'skills-sh') {
536
+ const isUserScope = props.viewScope === 'user'
537
+ const { isInstalled, isInstalledInViewScope, removeScope, displayableScopes } = computeSeedrScopes(props.installedScopes, isUserScope)
538
+ installedElsewhere = isInstalled && !isInstalledInViewScope
539
+
540
+ topLeft = (
541
+ <RegistryCardBadgeRow
542
+ isInstalled={isInstalled}
543
+ scopes={displayableScopes}
544
+ />
545
+ )
546
+
547
+ subtitle = <>by {props.author}</>
548
+
549
+ installButton = isInstalledInViewScope && removeScope ? (
550
+ <IconButton
551
+ icon={<Trash2 className="w-3.5 h-3.5" />}
552
+ onClick={() => props.onRemove?.()}
553
+ size="sm"
554
+ color="red"
555
+ tooltip={{ description: `Remove ${name} from ${removeScope}` }}
556
+ />
557
+ ) : installedElsewhere ? (
558
+ <IconButton
559
+ icon={<FolderPlus className="w-3.5 h-3.5" />}
560
+ onClick={() => setShowScopeConfirm(true)}
561
+ size="sm"
562
+ color="blue"
563
+ tooltip={{ description: ALREADY_AT_USER }}
564
+ />
565
+ ) : (
566
+ <IconButton
567
+ icon={<Plus className="w-3.5 h-3.5" />}
568
+ onClick={props.onInstall}
569
+ size="sm"
570
+ color="green"
571
+ tooltip={{ description: `Add ${name}` }}
572
+ />
573
+ )
574
+
575
+ const allTools = props.allToolKeys ?? []
576
+ bottomLogos = allTools.map((tool) => (
577
+ <Tooltip key={tool} content={{ description: `Compatible with ${AI_TOOL_NAMES[tool as AiToolKey] ?? tool}` }} position="top">
578
+ <AiToolIcon tool={tool} size={14} />
579
+ </Tooltip>
580
+ ))
581
+ if (props.installs != null && props.installs > 0) {
582
+ bottomStats = [(
583
+ <Tooltip key="installs" content={{ description: `${props.installs.toLocaleString()} installs` }} position="top">
584
+ <span className="flex items-center gap-1 text-[10px] text-neutral-500">
585
+ <Download className="w-3 h-3" />
586
+ {props.installs.toLocaleString()}
587
+ </span>
588
+ </Tooltip>
589
+ )]
590
+ }
591
+ }
592
+
593
+ // ── Render ──
594
+
595
+ const borderClass = flash === 'success'
596
+ ? 'border-green-500/60 ring-1 ring-green-500/30 bg-green-500/5'
597
+ : flash === 'error'
598
+ ? 'border-red-500/60 ring-1 ring-red-500/30 bg-red-500/5'
599
+ : flash === 'warning'
600
+ ? 'border-amber-500/60 ring-1 ring-amber-500/30 bg-amber-500/5'
601
+ : 'border-neutral-700'
602
+
603
+ return (
604
+ <div
605
+ role="button"
606
+ tabIndex={0}
607
+ className={`group text-left w-full cursor-pointer border rounded-lg p-3 transition-all duration-500 hover:border-neutral-600 flex flex-col bg-neutral-800/50 hover:bg-neutral-800 ${borderClass}`}
608
+ onClick={onClick}
609
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } }}
610
+ >
611
+ {/* Top row */}
612
+ <div className="flex items-start justify-between gap-2 mb-2">
613
+ <div className="flex-1 min-w-0 min-h-7 overflow-hidden">
614
+ {topLeft}
615
+ </div>
616
+ <div className="relative w-7 h-7 flex items-center justify-center shrink-0">
617
+ {isInstalling ? (
618
+ <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
619
+ ) : (
620
+ <>
621
+ <Tooltip content={{ description: `${typeLabel} extension` }} position="top">
622
+ <TypeIcon className={`w-4 h-4 group-hover:opacity-0 transition-opacity ${typeIconColor}`} />
623
+ </Tooltip>
624
+ {installButton && (
625
+ <div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
626
+ {installButton}
627
+ </div>
628
+ )}
629
+ </>
630
+ )}
631
+ </div>
632
+ </div>
633
+
634
+ {/* Name */}
635
+ <h3 className="text-sm font-medium text-white">{name}</h3>
636
+
637
+ {/* Subtitle */}
638
+ {subtitle && <div className="text-xs text-neutral-400 mb-1.5">{subtitle}</div>}
639
+
640
+ {/* Description */}
641
+ {description && <p className="text-xs text-neutral-500 mb-3 flex-grow line-clamp-2">{description}</p>}
642
+
643
+ {/* Error/warning message */}
644
+ {errorMessage && (
645
+ <p className={`text-[11px] mb-2 break-all ${flash === 'warning' ? 'text-amber-400' : 'text-red-400'}`}>{errorMessage}</p>
646
+ )}
647
+
648
+ {/* Bottom row */}
649
+ {(bottomLogos.length > 0 || bottomStats.length > 0 || updatedAt) && (() => {
650
+ const dateNode = updatedAt ? (
651
+ <Tooltip content={{ description: onDateClick ? `Last updated ${formatFullDate(updatedAt)} \u00b7 Click to sort by date` : `Last updated ${formatFullDate(updatedAt)}` }} position="top">
652
+ <span
653
+ className={`flex items-center gap-1 text-[10px] text-neutral-500 whitespace-nowrap${onDateClick ? ' cursor-pointer hover:brightness-125 transition-all' : ''}`}
654
+ onClick={onDateClick ? (e: MouseEvent) => { e.stopPropagation(); onDateClick() } : undefined}
655
+ >
656
+ <Clock className="w-3 h-3" />
657
+ {formatRelativeTime(updatedAt)}
658
+ </span>
659
+ </Tooltip>
660
+ ) : null
661
+
662
+ const hasStats = bottomStats.length > 0
663
+
664
+ return (
665
+ <div className="mt-auto pt-1 flex items-end">
666
+ <div className={`${hasStats ? 'w-1/3' : 'w-2/3'} flex flex-wrap gap-1.5 content-end`}>
667
+ {bottomLogos}
668
+ </div>
669
+ {hasStats && (
670
+ <div className="w-1/3 flex justify-center items-end gap-2">
671
+ {bottomStats}
672
+ </div>
673
+ )}
674
+ <div className="w-1/3 flex justify-end items-end">
675
+ {dateNode}
676
+ </div>
677
+ </div>
678
+ )
679
+ })()}
680
+
681
+ {showScopeConfirm && (() => {
682
+ // Use custom renderScopeConfirm if provided, otherwise show a basic confirm
683
+ if ('renderScopeConfirm' in props && props.renderScopeConfirm) {
684
+ return (
685
+ <div onClick={(e) => e.stopPropagation()}>
686
+ {props.renderScopeConfirm({ isOpen: true, onClose: () => setShowScopeConfirm(false), onConfirm: props.onInstall, name })}
687
+ </div>
688
+ )
689
+ }
690
+ // Basic fallback: just install on confirm
691
+ return (
692
+ <div onClick={(e) => e.stopPropagation()}>
693
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={() => setShowScopeConfirm(false)}>
694
+ <div className="bg-neutral-800 border border-neutral-700 rounded-lg p-4 max-w-sm" onClick={(e) => e.stopPropagation()}>
695
+ <h3 className="text-sm font-medium text-neutral-200 mb-2">{ALREADY_AT_USER}</h3>
696
+ <p className="text-xs text-neutral-400 mb-4">
697
+ <strong className="text-neutral-300">{name}</strong> is already installed at user level and available to all projects. Do you also want to install it at project level?
698
+ </p>
699
+ <div className="flex justify-end gap-2">
700
+ <button type="button" onClick={() => setShowScopeConfirm(false)} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors cursor-pointer">Cancel</button>
701
+ <button type="button" onClick={() => { setShowScopeConfirm(false); props.onInstall() }} className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-500 transition-colors cursor-pointer">Install to project</button>
702
+ </div>
703
+ </div>
704
+ </div>
705
+ </div>
706
+ )
707
+ })()}
708
+ </div>
709
+ )
710
+ }