@toolr/ui-design 0.1.7 → 0.1.9

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 (101) hide show
  1. package/ai-manifest.json +35 -20
  2. package/components/composites/dashboard-list-item.tsx +172 -0
  3. package/components/composites/dashboard-panel.tsx +218 -0
  4. package/components/content/info-panel-primitives.tsx +9 -8
  5. package/components/diagrams/diagram-utils.tsx +2 -1
  6. package/components/hooks/use-dropdown-portal.ts +39 -0
  7. package/components/hooks/use-modal-behavior.ts +32 -3
  8. package/components/lib/accent-context.ts +10 -0
  9. package/components/lib/{ai-tools.tsx → coding-agents.tsx} +23 -8
  10. package/components/lib/custom-icons.tsx +37 -0
  11. package/components/lib/git-providers.tsx +39 -0
  12. package/components/lib/theme-engine.ts +59 -10
  13. package/components/lib/toolr-brand.tsx +23 -9
  14. package/components/sections/captured-issues/captured-issues-panel.tsx +17 -8
  15. package/components/sections/{ai-tools-paths/tools-paths-panel.tsx → coding-agent-paths/agent-paths-panel.tsx} +70 -62
  16. package/components/sections/coding-agent-paths/index.ts +37 -0
  17. package/components/sections/{ai-tools-paths → coding-agent-paths}/types.ts +28 -28
  18. package/components/sections/coding-agent-paths/use-agent-paths.ts +159 -0
  19. package/components/sections/golden-snapshots/file-diff-viewer.tsx +11 -10
  20. package/components/sections/golden-snapshots/golden-sync-panel.tsx +12 -3
  21. package/components/sections/golden-snapshots/snapshot-manager.tsx +9 -7
  22. package/components/sections/golden-snapshots/status-overview.tsx +8 -8
  23. package/components/sections/golden-snapshots/version-manager.tsx +6 -6
  24. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +3 -3
  25. package/components/sections/prompt-editor/index.ts +1 -1
  26. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +13 -5
  27. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +18 -10
  28. package/components/sections/prompt-editor/types.ts +2 -2
  29. package/components/sections/report-bug/report-bug-form.tsx +12 -4
  30. package/components/sections/report-bug/screenshot-uploader.tsx +11 -3
  31. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +12 -4
  32. package/components/sections/snapshot-browser/snapshot-tree.tsx +5 -4
  33. package/components/sections/snapshot-browser/types.ts +1 -1
  34. package/components/sections/snippets-editor/snippets-editor.tsx +16 -9
  35. package/components/settings/SettingsHeader.tsx +2 -2
  36. package/components/settings/SettingsPanel.tsx +11 -3
  37. package/components/settings/SettingsTreeNav.tsx +15 -9
  38. package/components/ui/action-dialog.tsx +37 -35
  39. package/components/ui/ai-action-button.tsx +12 -11
  40. package/components/ui/ai-execution-action-buttons.tsx +13 -5
  41. package/components/ui/badge.tsx +17 -6
  42. package/components/ui/bottom-panel-header.tsx +9 -5
  43. package/components/ui/breadcrumb.tsx +14 -6
  44. package/components/ui/{extension-list-card.tsx → capability-list-card.tsx} +14 -6
  45. package/components/ui/checkbox.tsx +23 -14
  46. package/components/ui/collapsible-section.tsx +38 -28
  47. package/components/ui/confirm-badge.tsx +17 -6
  48. package/components/ui/cookie-consent.tsx +13 -7
  49. package/components/ui/detail-section.tsx +24 -16
  50. package/components/ui/detail-view-wrapper.tsx +30 -22
  51. package/components/ui/editor-placeholder-card.tsx +28 -24
  52. package/components/ui/editor-toolbar.tsx +7 -4
  53. package/components/ui/execution-details-panel.tsx +10 -5
  54. package/components/ui/file-structure-section.tsx +3 -3
  55. package/components/ui/file-tree.tsx +7 -5
  56. package/components/ui/files-panel.tsx +147 -27
  57. package/components/ui/filter-dropdown.tsx +88 -75
  58. package/components/ui/form-actions.tsx +21 -11
  59. package/components/ui/frontmatter-form-header.tsx +10 -2
  60. package/components/ui/icon-button.tsx +27 -14
  61. package/components/ui/input.tsx +15 -7
  62. package/components/ui/label.tsx +9 -5
  63. package/components/ui/layout-tab-bar.tsx +11 -9
  64. package/components/ui/modal.tsx +26 -8
  65. package/components/ui/nav-card.tsx +7 -4
  66. package/components/ui/navigation-bar.tsx +40 -12
  67. package/components/ui/number-input.tsx +14 -4
  68. package/components/ui/project-explorer.tsx +666 -0
  69. package/components/ui/registry-browser.tsx +12 -1
  70. package/components/ui/registry-card.tsx +49 -42
  71. package/components/ui/registry-detail.tsx +34 -11
  72. package/components/ui/resizable-textarea.tsx +18 -11
  73. package/components/ui/scope-badge.tsx +18 -11
  74. package/components/ui/segmented-toggle.tsx +7 -2
  75. package/components/ui/select.tsx +17 -11
  76. package/components/ui/selection-grid.tsx +40 -37
  77. package/components/ui/setting-row.tsx +6 -4
  78. package/components/ui/settings-card.tsx +12 -5
  79. package/components/ui/settings-info-box.tsx +9 -6
  80. package/components/ui/settings-section-title.tsx +14 -2
  81. package/components/ui/snapshot-card.tsx +10 -2
  82. package/components/ui/snippets-panel.tsx +4 -2
  83. package/components/ui/sort-dropdown.tsx +45 -32
  84. package/components/ui/status-card.tsx +9 -1
  85. package/components/ui/tab-bar.tsx +26 -13
  86. package/components/ui/toggle.tsx +31 -17
  87. package/components/ui/tooltip.tsx +14 -6
  88. package/dist/content.js +8 -8
  89. package/dist/diagrams.d.ts +0 -1
  90. package/dist/index.d.ts +431 -186
  91. package/dist/index.js +3119 -1724
  92. package/dist/tokens/primitives.css +28 -6
  93. package/dist/tokens/semantic.css +15 -15
  94. package/dist/tokens/theme.css +23 -0
  95. package/index.ts +25 -11
  96. package/package.json +9 -1
  97. package/tokens/primitives.css +28 -6
  98. package/tokens/semantic.css +15 -15
  99. package/tokens/theme.css +23 -0
  100. package/components/sections/ai-tools-paths/index.ts +0 -37
  101. package/components/sections/ai-tools-paths/use-tools-paths.ts +0 -159
@@ -1,10 +1,12 @@
1
1
  /** File explorer panel with tree view, folder collapse/expand, search, and file selection. */
2
2
 
3
- import { useState, useMemo } from 'react'
3
+ import { useState, useMemo, type ReactNode } from 'react'
4
4
  import { Folder, FolderOpen, File, FileCode, FileText, FileJson, Image, ChevronRight, Search, MoreVertical } from 'lucide-react'
5
5
  import type { LucideIcon } from 'lucide-react'
6
6
  import { iconMap, type IconName } from './icon-button.tsx'
7
7
  import { cn } from '../lib/cn.ts'
8
+ import { ACCENT_ICON, type AccentColor } from '../lib/form-colors.ts'
9
+ import { useAccentColor } from '../lib/accent-context.ts'
8
10
 
9
11
  const ACCENT_SELECTED: Record<string, string> = {
10
12
  blue: 'bg-blue-400/15 text-blue-400',
@@ -19,6 +21,19 @@ const ACCENT_SELECTED: Record<string, string> = {
19
21
  violet: 'bg-violet-400/15 text-violet-400',
20
22
  }
21
23
 
24
+ const ACCENT_SELECTED_BG: Record<string, string> = {
25
+ blue: 'bg-blue-500/20',
26
+ purple: 'bg-purple-500/20',
27
+ orange: 'bg-orange-500/20',
28
+ green: 'bg-green-500/20',
29
+ pink: 'bg-pink-500/20',
30
+ amber: 'bg-amber-500/20',
31
+ emerald: 'bg-emerald-500/20',
32
+ teal: 'bg-teal-500/20',
33
+ sky: 'bg-sky-500/20',
34
+ violet: 'bg-violet-500/20',
35
+ }
36
+
22
37
  const EXTENSION_ICON_MAP: Record<string, LucideIcon> = {
23
38
  ts: FileCode,
24
39
  tsx: FileCode,
@@ -54,9 +69,29 @@ export interface FilesPanelProps {
54
69
  showSearch?: boolean
55
70
  className?: string
56
71
  accentColor?: string
72
+ /** Right-click handler for tree nodes */
73
+ onContextMenu?: (e: React.MouseEvent, entry: FileEntry) => void
74
+ /** Path of the selected/highlighted folder (separate from file selection) */
75
+ selectedFolderPath?: string
76
+ /** Callback when a folder row is clicked (for folder selection). If set, chevron toggles expand while row selects. */
77
+ onSelectFolder?: (path: string) => void
78
+ /** Custom content rendered after the file/folder name (e.g. badges, labels) */
79
+ renderBadges?: (entry: FileEntry) => ReactNode
80
+ /** Custom hover actions rendered at the right edge of each row */
81
+ renderNodeActions?: (entry: FileEntry) => ReactNode
82
+ /** Set of paths that should render in a muted/dimmed style */
83
+ hiddenPaths?: Set<string>
84
+ /** Custom content above the tree (e.g. filter badges, creation inputs) */
85
+ header?: ReactNode
86
+ /** Custom footer content. When provided, replaces the default file count footer. */
87
+ footer?: ReactNode
88
+ /** Controlled expand state. When provided, FilesPanel uses this instead of internal state. */
89
+ expandedPaths?: Set<string>
90
+ /** Controlled toggle callback. Required when expandedPaths is provided. */
91
+ onToggleExpand?: (path: string) => void
57
92
  }
58
93
 
59
- function collectAllFolderPaths(entries: FileEntry[]): Set<string> {
94
+ export function collectAllFolderPaths(entries: FileEntry[]): Set<string> {
60
95
  const paths = new Set<string>()
61
96
  function walk(items: FileEntry[]) {
62
97
  for (const entry of items) {
@@ -70,7 +105,7 @@ function collectAllFolderPaths(entries: FileEntry[]): Set<string> {
70
105
  return paths
71
106
  }
72
107
 
73
- function countFiles(entries: FileEntry[]): number {
108
+ export function countFiles(entries: FileEntry[]): number {
74
109
  let count = 0
75
110
  for (const entry of entries) {
76
111
  if (entry.type === 'file') count++
@@ -79,6 +114,17 @@ function countFiles(entries: FileEntry[]): number {
79
114
  return count
80
115
  }
81
116
 
117
+ export function countFolders(entries: FileEntry[]): number {
118
+ let count = 0
119
+ for (const entry of entries) {
120
+ if (entry.type === 'folder') {
121
+ count++
122
+ if (entry.children) count += countFolders(entry.children)
123
+ }
124
+ }
125
+ return count
126
+ }
127
+
82
128
  function getFileIcon(entry: FileEntry): LucideIcon {
83
129
  if (entry.icon && iconMap[entry.icon]) return iconMap[entry.icon]!
84
130
  if (entry.type === 'folder') return Folder
@@ -112,61 +158,96 @@ interface FileNodeProps {
112
158
  entry: FileEntry
113
159
  depth: number
114
160
  selectedPath?: string
161
+ selectedFolderPath?: string
115
162
  expandedPaths: Set<string>
163
+ hiddenPaths?: Set<string>
116
164
  onToggleExpand: (path: string) => void
117
165
  onSelect?: (path: string) => void
166
+ onSelectFolder?: (path: string) => void
118
167
  onAction?: (action: string, path: string) => void
168
+ onContextMenu?: (e: React.MouseEvent, entry: FileEntry) => void
169
+ renderBadges?: (entry: FileEntry) => ReactNode
170
+ renderNodeActions?: (entry: FileEntry) => ReactNode
119
171
  accentColor: string
120
172
  }
121
173
 
122
- function FileNode({ entry, depth, selectedPath, expandedPaths, onToggleExpand, onSelect, onAction, accentColor }: FileNodeProps) {
174
+ function FileNode({
175
+ entry, depth, selectedPath, selectedFolderPath, expandedPaths, hiddenPaths,
176
+ onToggleExpand, onSelect, onSelectFolder, onAction, onContextMenu,
177
+ renderBadges, renderNodeActions, accentColor,
178
+ }: FileNodeProps) {
123
179
  const isFolder = entry.type === 'folder'
124
180
  const isExpanded = isFolder && expandedPaths.has(entry.path)
125
- const isSelected = !isFolder && selectedPath === entry.path
181
+ const isFileSelected = !isFolder && selectedPath === entry.path
182
+ const isFolderSelected = isFolder && selectedFolderPath === entry.path
183
+ const isMuted = hiddenPaths?.has(entry.path)
126
184
  const Icon = isFolder ? getFolderIcon(isExpanded, entry) : getFileIcon(entry)
127
185
 
186
+ const hasFolderSelection = !!onSelectFolder
187
+ const handleClick = isFolder
188
+ ? (hasFolderSelection ? () => onSelectFolder!(entry.path) : () => onToggleExpand(entry.path))
189
+ : () => onSelect?.(entry.path)
190
+
191
+ const isHighlighted = isFileSelected || isFolderSelected
192
+
128
193
  return (
129
194
  <li>
130
- <button
131
- type="button"
132
- onClick={isFolder ? () => onToggleExpand(entry.path) : () => onSelect?.(entry.path)}
195
+ <div
133
196
  className={cn(
134
- 'group flex items-center gap-1.5 w-full py-1 px-2 rounded text-sm transition-colors cursor-pointer',
135
- isSelected
136
- ? ACCENT_SELECTED[accentColor] ?? ACCENT_SELECTED.blue
197
+ 'group/node relative flex items-center gap-1.5 w-full py-1 px-2 text-sm transition-colors cursor-pointer select-none',
198
+ isMuted && 'opacity-50',
199
+ isHighlighted
200
+ ? (isFolderSelected ? ACCENT_SELECTED_BG[accentColor] ?? ACCENT_SELECTED_BG.blue : ACCENT_SELECTED[accentColor] ?? ACCENT_SELECTED.blue)
137
201
  : 'text-neutral-400 hover:bg-neutral-700/40 hover:text-neutral-200',
138
202
  )}
139
203
  style={{ paddingLeft: `${depth * 16 + 8}px` }}
204
+ onClick={handleClick}
205
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e, entry) : undefined}
140
206
  >
141
207
  {isFolder ? (
142
- <ChevronRight
143
- className={cn('w-3 h-3 shrink-0 transition-transform', isExpanded && 'rotate-90')}
144
- />
208
+ hasFolderSelection ? (
209
+ // Chevron is a separate click target when folders have selection behavior
210
+ <span
211
+ className="shrink-0 p-0.5 -m-0.5 rounded hover:bg-neutral-600/40 cursor-pointer"
212
+ onClick={(e) => { e.stopPropagation(); onToggleExpand(entry.path) }}
213
+ >
214
+ <ChevronRight className={cn('w-3 h-3 transition-transform', isExpanded && 'rotate-90')} />
215
+ </span>
216
+ ) : (
217
+ <ChevronRight className={cn('w-3 h-3 shrink-0 transition-transform', isExpanded && 'rotate-90')} />
218
+ )
145
219
  ) : (
146
220
  <span className="w-3 shrink-0" />
147
221
  )}
148
222
  <Icon
149
- className="w-3.5 h-3.5 shrink-0"
223
+ className={cn('w-3.5 h-3.5 shrink-0', !entry.color && (ACCENT_ICON[accentColor as AccentColor] ?? ACCENT_ICON.blue))}
150
224
  style={entry.color ? { color: entry.color } : undefined}
151
225
  />
152
226
  <span className="truncate">{entry.name}</span>
153
- {entry.badge && (
227
+ {renderBadges ? renderBadges(entry) : entry.badge && (
154
228
  <span className="ml-auto shrink-0 px-1.5 py-0.5 text-xs rounded bg-neutral-700 text-neutral-500">
155
229
  {entry.badge}
156
230
  </span>
157
231
  )}
158
- {onAction && (
232
+ {renderNodeActions ? (
233
+ <div
234
+ className="absolute right-2 top-1/2 -translate-y-1/2 hidden group-hover/node:flex items-center gap-0.5 bg-neutral-960/90 rounded px-0.5"
235
+ onClick={(e) => e.stopPropagation()}
236
+ >
237
+ {renderNodeActions(entry)}
238
+ </div>
239
+ ) : onAction && (
159
240
  <span
160
241
  role="button"
161
242
  tabIndex={-1}
162
- className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-neutral-700 text-neutral-500 hover:text-neutral-200 transition-all"
243
+ className="ml-auto shrink-0 opacity-0 group-hover/node:opacity-100 p-0.5 rounded hover:bg-neutral-700 text-neutral-500 hover:text-neutral-200 transition-all"
163
244
  onClick={(e) => { e.stopPropagation(); onAction('menu', entry.path) }}
164
245
  onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onAction('menu', entry.path) } }}
165
246
  >
166
247
  <MoreVertical className="w-3 h-3" />
167
248
  </span>
168
249
  )}
169
- </button>
250
+ </div>
170
251
  {isFolder && isExpanded && entry.children && (
171
252
  <ul>
172
253
  {entry.children.map((child) => (
@@ -175,10 +256,16 @@ function FileNode({ entry, depth, selectedPath, expandedPaths, onToggleExpand, o
175
256
  entry={child}
176
257
  depth={depth + 1}
177
258
  selectedPath={selectedPath}
259
+ selectedFolderPath={selectedFolderPath}
178
260
  expandedPaths={expandedPaths}
261
+ hiddenPaths={hiddenPaths}
179
262
  onToggleExpand={onToggleExpand}
180
263
  onSelect={onSelect}
264
+ onSelectFolder={onSelectFolder}
181
265
  onAction={onAction}
266
+ onContextMenu={onContextMenu}
267
+ renderBadges={renderBadges}
268
+ renderNodeActions={renderNodeActions}
182
269
  accentColor={accentColor}
183
270
  />
184
271
  ))}
@@ -195,11 +282,25 @@ export function FilesPanel({
195
282
  onAction,
196
283
  showSearch = false,
197
284
  className,
198
- accentColor = 'blue',
285
+ accentColor: accentColorProp,
286
+ onContextMenu,
287
+ selectedFolderPath,
288
+ onSelectFolder,
289
+ renderBadges,
290
+ renderNodeActions,
291
+ hiddenPaths,
292
+ header,
293
+ footer,
294
+ expandedPaths: controlledExpandedPaths,
295
+ onToggleExpand: controlledOnToggleExpand,
199
296
  }: FilesPanelProps) {
200
- const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => collectAllFolderPaths(files))
297
+ const accentColor = accentColorProp ?? useAccentColor() ?? 'blue'
298
+ const [internalExpandedPaths, setInternalExpandedPaths] = useState<Set<string>>(() => collectAllFolderPaths(files))
201
299
  const [searchQuery, setSearchQuery] = useState('')
202
300
 
301
+ const isControlled = controlledExpandedPaths !== undefined
302
+ const expandedPaths = isControlled ? controlledExpandedPaths : internalExpandedPaths
303
+
203
304
  const fileCount = useMemo(() => countFiles(files), [files])
204
305
 
205
306
  const displayedFiles = useMemo(
@@ -208,7 +309,11 @@ export function FilesPanel({
208
309
  )
209
310
 
210
311
  function handleToggleExpand(path: string) {
211
- setExpandedPaths((prev: Set<string>) => {
312
+ if (isControlled) {
313
+ controlledOnToggleExpand?.(path)
314
+ return
315
+ }
316
+ setInternalExpandedPaths((prev: Set<string>) => {
212
317
  const next = new Set(prev)
213
318
  if (next.has(path)) next.delete(path)
214
319
  else next.add(path)
@@ -217,11 +322,14 @@ export function FilesPanel({
217
322
  }
218
323
 
219
324
  return (
220
- <div className={cn('flex flex-col bg-neutral-800 rounded-lg overflow-hidden', className)}>
221
- <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
222
- <span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">Files</span>
223
- <span className="text-xs text-neutral-500">{fileCount} files</span>
224
- </div>
325
+ <div className={cn('flex flex-col bg-neutral-960 rounded-lg overflow-hidden', className)}>
326
+ {!header && (
327
+ <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
328
+ <span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">Files</span>
329
+ <span className="text-xs text-neutral-500">{fileCount} files</span>
330
+ </div>
331
+ )}
332
+ {header}
225
333
  {showSearch && (
226
334
  <div className="px-2 py-2 border-b border-neutral-700">
227
335
  <div className="flex items-center gap-1.5 px-2 py-1 bg-[var(--background)] border border-neutral-700 rounded text-sm">
@@ -244,10 +352,16 @@ export function FilesPanel({
244
352
  entry={entry}
245
353
  depth={0}
246
354
  selectedPath={selectedPath}
355
+ selectedFolderPath={selectedFolderPath}
247
356
  expandedPaths={expandedPaths}
357
+ hiddenPaths={hiddenPaths}
248
358
  onToggleExpand={handleToggleExpand}
249
359
  onSelect={onSelect}
360
+ onSelectFolder={onSelectFolder}
250
361
  onAction={onAction}
362
+ onContextMenu={onContextMenu}
363
+ renderBadges={renderBadges}
364
+ renderNodeActions={renderNodeActions}
251
365
  accentColor={accentColor}
252
366
  />
253
367
  ))}
@@ -256,6 +370,12 @@ export function FilesPanel({
256
370
  <p className="text-xs text-neutral-500 text-center py-4">No files found</p>
257
371
  )}
258
372
  </div>
373
+ {footer}
374
+ {!footer && !header && (
375
+ <div className="flex items-center justify-between px-3 py-2 border-t border-neutral-700">
376
+ <span className="text-xs text-neutral-500">{fileCount} files</span>
377
+ </div>
378
+ )}
259
379
  </div>
260
380
  )
261
381
  }
@@ -1,13 +1,15 @@
1
- import { useState, useEffect, useRef, type ReactNode } from 'react'
1
+ import { useState, useEffect, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { ChevronDown, Check, X, Search, Filter } from 'lucide-react'
3
4
  import { useClickOutside } from '../hooks/use-click-outside.ts'
4
- import { useDropdownMaxHeight } from '../hooks/use-dropdown-max-height.ts'
5
+ import { useDropdownPortal } from '../hooks/use-dropdown-portal.ts'
5
6
  import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
7
+ import { useAccentColor } from '../lib/accent-context.ts'
6
8
 
7
9
  const SEARCH_THRESHOLD = 20
8
10
 
9
11
  const VARIANT_CLASSES = {
10
- filled: { bg: 'bg-neutral-800', hoverBg: 'hover:bg-neutral-700' },
12
+ filled: { bg: 'bg-neutral-960', hoverBg: 'hover:bg-neutral-700' },
11
13
  outline: { bg: 'bg-transparent', hoverBg: 'hover:bg-neutral-700' },
12
14
  }
13
15
 
@@ -19,7 +21,7 @@ export interface FilterDropdownProps {
19
21
  labelExtra?: ReactNode
20
22
  clearable?: boolean
21
23
  variant?: 'filled' | 'outline'
22
- color?: FormColor
24
+ accentColor?: FormColor
23
25
  }
24
26
 
25
27
  export function FilterDropdown({
@@ -30,19 +32,20 @@ export function FilterDropdown({
30
32
  labelExtra,
31
33
  clearable = true,
32
34
  variant = 'outline',
33
- color = 'blue',
35
+ accentColor,
34
36
  }: FilterDropdownProps) {
37
+ const contextAccent = useAccentColor()
38
+ const effectiveColor = accentColor ?? contextAccent ?? 'blue'
35
39
  const [isOpen, setIsOpen] = useState(false)
36
40
  const [search, setSearch] = useState('')
37
41
  const [highlightIdx, setHighlightIdx] = useState(-1)
38
- const ref = useRef<HTMLDivElement>(null)
39
- const menuRef = useDropdownMaxHeight<HTMLDivElement>(isOpen)
40
- const searchRef = useRef<HTMLInputElement>(null)
42
+ const { triggerRef, menuRef, position } = useDropdownPortal(isOpen)
43
+ const searchRef = { current: null as HTMLInputElement | null }
41
44
  const isActive = value !== 'all'
42
45
  const showSearch = options.length > SEARCH_THRESHOLD
43
46
  const v = VARIANT_CLASSES[variant]
44
47
 
45
- useClickOutside(ref, isOpen, () => setIsOpen(false))
48
+ useClickOutside([triggerRef, menuRef], isOpen, () => setIsOpen(false))
46
49
 
47
50
  useEffect(() => {
48
51
  if (!isOpen) { setSearch(''); setHighlightIdx(-1) }
@@ -85,89 +88,99 @@ export function FilterDropdown({
85
88
  }
86
89
  }
87
90
 
91
+ const menu = isOpen && createPortal(
92
+ <div
93
+ ref={menuRef}
94
+ role="listbox"
95
+ style={{ position: 'fixed', top: position.top, left: position.left, minWidth: position.minWidth, zIndex: 9999 }}
96
+ className={`whitespace-nowrap bg-[var(--popover)] border ${FORM_COLORS[effectiveColor].border} rounded-lg shadow-lg overflow-hidden`}
97
+ >
98
+ {showSearch && (
99
+ <div className={`sticky top-0 p-1.5 bg-[var(--popover)] border-b ${FORM_COLORS[effectiveColor].border} z-10`}>
100
+ <div className="relative">
101
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-neutral-500" />
102
+ <input
103
+ ref={(el) => { searchRef.current = el }}
104
+ type="text"
105
+ value={search}
106
+ onChange={(e) => setSearch(e.target.value)}
107
+ onKeyDown={handleKeyDown}
108
+ placeholder="Search..."
109
+ className={`w-full pl-6 pr-2 py-1 text-sm bg-[var(--popover)] border border-neutral-600 rounded text-neutral-200 placeholder-neutral-500 outline-none ${FORM_COLORS[effectiveColor].focus}`}
110
+ />
111
+ </div>
112
+ </div>
113
+ )}
114
+ {hasAllOption && (
115
+ <button
116
+ data-idx={0}
117
+ onClick={() => handleSelect('all')}
118
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors cursor-pointer ${
119
+ highlightIdx === 0
120
+ ? `${FORM_COLORS[effectiveColor].selectedBg} text-neutral-200`
121
+ : !isActive ? `${FORM_COLORS[effectiveColor].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
122
+ }`}
123
+ >
124
+ <Check className={`w-3 h-3 shrink-0 ${!isActive ? FORM_COLORS[effectiveColor].accent : 'invisible'}`} />
125
+ <span>All</span>
126
+ </button>
127
+ )}
128
+ {filtered.map((opt, i) => {
129
+ const idx = i + (hasAllOption ? 1 : 0)
130
+ const isHighlighted = highlightIdx === idx
131
+ const isSelected = value === opt.value
132
+ return (
133
+ <button
134
+ key={opt.value}
135
+ data-idx={idx}
136
+ onClick={() => handleSelect(opt.value)}
137
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors cursor-pointer ${
138
+ isHighlighted
139
+ ? `${FORM_COLORS[effectiveColor].selectedBg} text-neutral-200`
140
+ : isSelected ? `${FORM_COLORS[effectiveColor].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
141
+ }`}
142
+ >
143
+ <Check className={`w-3 h-3 shrink-0 ${isSelected ? FORM_COLORS[effectiveColor].accent : 'invisible'}`} />
144
+ <span>{opt.label}</span>
145
+ </button>
146
+ )
147
+ })}
148
+ {showSearch && search && filtered.length === 0 && (
149
+ <div className="px-3 py-2 text-sm text-neutral-500">No matches</div>
150
+ )}
151
+ </div>,
152
+ document.body,
153
+ )
154
+
88
155
  return (
89
- <div className="relative flex items-center" ref={ref} onKeyDown={handleKeyDown}>
156
+ <div className="relative flex items-center" ref={triggerRef} onKeyDown={handleKeyDown}>
90
157
  <button
158
+ aria-expanded={isOpen}
159
+ aria-haspopup="listbox"
91
160
  onClick={() => setIsOpen(!isOpen)}
92
- className={`flex items-center gap-1.5 h-7 px-2 rounded-md border ${v.bg} text-sm transition-colors cursor-pointer ${
161
+ className={`flex items-center gap-1.5 py-1 px-2 rounded-md border ${v.bg} text-sm transition-colors cursor-pointer ${
93
162
  isActive
94
- ? `${clearable ? 'rounded-r-none border-r-0' : ''} ${FORM_COLORS[color].border} text-neutral-200 ${FORM_COLORS[color].hover}`
163
+ ? `${clearable ? 'rounded-r-none border-r-0' : ''} ${FORM_COLORS[effectiveColor].border} text-neutral-200 ${FORM_COLORS[effectiveColor].hover}`
95
164
  : isOpen
96
- ? `${FORM_COLORS[color].border} text-neutral-200`
97
- : `${FORM_COLORS[color].border} text-neutral-400 ${FORM_COLORS[color].hover} hover:text-neutral-200`
165
+ ? `${FORM_COLORS[effectiveColor].border} text-neutral-200`
166
+ : `${FORM_COLORS[effectiveColor].border} text-neutral-400 ${FORM_COLORS[effectiveColor].hover} hover:text-neutral-200`
98
167
  }`}
99
168
  >
100
- <Filter className={`w-3 h-3 ${isActive ? FORM_COLORS[color].accent : ''}`} />
169
+ <Filter className={`w-3 h-3 ${isActive ? FORM_COLORS[effectiveColor].accent : ''}`} />
101
170
  {labelExtra}
102
- <span className="whitespace-nowrap">{selectedLabel}</span>
171
+ <span className="truncate">{selectedLabel}</span>
103
172
  <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
104
173
  </button>
105
174
  {isActive && clearable && (
106
175
  <button
176
+ aria-label="Clear filter"
107
177
  onClick={() => onChange('all')}
108
- className={`flex items-center justify-center h-7 px-1.5 rounded-r-md border border-l-0 ${FORM_COLORS[color].border} ${v.bg} text-neutral-400 ${FORM_COLORS[color].hover} hover:text-neutral-200 transition-colors cursor-pointer`}
178
+ className={`flex items-center justify-center py-1 px-1.5 rounded-r-md border border-l-0 ${FORM_COLORS[effectiveColor].border} ${v.bg} text-neutral-400 ${FORM_COLORS[effectiveColor].hover} hover:text-neutral-200 transition-colors cursor-pointer`}
109
179
  >
110
180
  <X className="w-3 h-3" />
111
181
  </button>
112
182
  )}
113
-
114
- {isOpen && (
115
- <div ref={menuRef} className={`absolute right-0 top-full z-50 mt-1 min-w-[140px] whitespace-nowrap bg-[var(--popover)] border ${FORM_COLORS[color].border} rounded-lg shadow-xl overflow-hidden`}>
116
- {showSearch && (
117
- <div className={`sticky top-0 p-1.5 bg-[var(--popover)] border-b ${FORM_COLORS[color].border} z-10`}>
118
- <div className="relative">
119
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-neutral-500" />
120
- <input
121
- ref={searchRef}
122
- type="text"
123
- value={search}
124
- onChange={(e) => setSearch(e.target.value)}
125
- onKeyDown={handleKeyDown}
126
- placeholder="Search..."
127
- className={`w-full pl-6 pr-2 py-1 text-sm bg-[var(--popover)] border border-neutral-600 rounded text-neutral-200 placeholder-neutral-500 outline-none ${FORM_COLORS[color].focus}`}
128
- />
129
- </div>
130
- </div>
131
- )}
132
- {hasAllOption && (
133
- <button
134
- data-idx={0}
135
- onClick={() => handleSelect('all')}
136
- className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors cursor-pointer ${
137
- highlightIdx === 0
138
- ? `${FORM_COLORS[color].selectedBg} text-neutral-200`
139
- : !isActive ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
140
- }`}
141
- >
142
- <Check className={`w-3 h-3 shrink-0 ${!isActive ? FORM_COLORS[color].accent : 'invisible'}`} />
143
- <span>All</span>
144
- </button>
145
- )}
146
- {filtered.map((opt, i) => {
147
- const idx = i + (hasAllOption ? 1 : 0)
148
- const isHighlighted = highlightIdx === idx
149
- const isSelected = value === opt.value
150
- return (
151
- <button
152
- key={opt.value}
153
- data-idx={idx}
154
- onClick={() => handleSelect(opt.value)}
155
- className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors cursor-pointer ${
156
- isHighlighted
157
- ? `${FORM_COLORS[color].selectedBg} 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
- <span>{opt.label}</span>
163
- </button>
164
- )
165
- })}
166
- {showSearch && search && filtered.length === 0 && (
167
- <div className="px-3 py-2 text-sm text-neutral-500">No matches</div>
168
- )}
169
- </div>
170
- )}
183
+ {menu}
171
184
  </div>
172
185
  )
173
186
  }
@@ -1,4 +1,7 @@
1
1
  import { IconButton, type IconName, type IconButtonProps, type IconButtonStatus } from './icon-button.tsx'
2
+ import { AccentColorProvider, useAccentColor } from '../lib/accent-context.ts'
3
+ import type { FormColor } from '../lib/form-colors.ts'
4
+ import { cn } from '../lib/cn.ts'
2
5
 
3
6
  export interface FormActionsProps {
4
7
  /** Cancel handler — renders X button. Optional (e.g. AlertModal has no cancel). */
@@ -17,7 +20,7 @@ export interface FormActionsProps {
17
20
  onConfirm?: () => void
18
21
  confirmTooltip?: string
19
22
  confirmIcon?: IconName
20
- confirmColor?: IconButtonProps['color']
23
+ confirmColor?: IconButtonProps['accentColor']
21
24
  confirmDisabled?: boolean
22
25
  confirmStatus?: IconButtonStatus
23
26
 
@@ -30,6 +33,7 @@ export interface FormActionsProps {
30
33
 
31
34
  border?: boolean
32
35
  padding?: 'compact' | 'normal' | 'modal'
36
+ accentColor?: FormColor
33
37
  }
34
38
 
35
39
  const PADDING_CLASSES = {
@@ -64,22 +68,27 @@ export function FormActions({
64
68
  statusText,
65
69
  border,
66
70
  padding = 'normal',
71
+ accentColor,
67
72
  }: FormActionsProps) {
73
+ const contextAccent = useAccentColor()
74
+ const effectiveColor = accentColor ?? contextAccent ?? 'blue'
68
75
  const showBorder = border ?? DEFAULT_BORDER[padding]
69
- const paddingClass = showBorder
70
- ? `${PADDING_CLASSES[padding]} ${BORDER_CLASS}`
71
- : PADDING_CLASSES[padding]
72
-
73
76
  const hasLeft = onBack || statusText
74
77
 
75
78
  return (
76
- <div className={`flex items-center ${hasLeft ? 'justify-between' : 'justify-end'} gap-2 ${paddingClass}`}>
79
+ <AccentColorProvider value={effectiveColor}>
80
+ <div className={cn(
81
+ 'flex items-center gap-2',
82
+ hasLeft ? 'justify-between' : 'justify-end',
83
+ PADDING_CLASSES[padding],
84
+ showBorder && BORDER_CLASS,
85
+ )}>
77
86
  {hasLeft && (
78
87
  <div className="flex items-center gap-2">
79
88
  {onBack && (
80
89
  <IconButton
81
90
  icon="arrow-left"
82
- color="neutral"
91
+ accentColor="neutral"
83
92
  onClick={onBack}
84
93
  tooltip={{ description: backTooltip }}
85
94
  />
@@ -91,7 +100,7 @@ export function FormActions({
91
100
  {onMinimize && (
92
101
  <IconButton
93
102
  icon="minimize"
94
- color="neutral"
103
+ accentColor="neutral"
95
104
  onClick={onMinimize}
96
105
  tooltip={{ description: minimizeTooltip }}
97
106
  />
@@ -99,7 +108,7 @@ export function FormActions({
99
108
  {onCancel && (
100
109
  <IconButton
101
110
  icon="x"
102
- color="neutral"
111
+ accentColor="neutral"
103
112
  onClick={onCancel}
104
113
  tooltip={{ description: cancelTooltip }}
105
114
  />
@@ -107,7 +116,7 @@ export function FormActions({
107
116
  {onConfirm && (
108
117
  <IconButton
109
118
  icon={confirmIcon}
110
- color={confirmColor}
119
+ accentColor={confirmColor}
111
120
  onClick={onConfirm}
112
121
  disabled={confirmDisabled}
113
122
  status={confirmStatus}
@@ -117,12 +126,13 @@ export function FormActions({
117
126
  {onNext && (
118
127
  <IconButton
119
128
  icon="arrow-right"
120
- color="blue"
129
+ accentColor="neutral"
121
130
  onClick={onNext}
122
131
  tooltip={{ description: nextTooltip }}
123
132
  />
124
133
  )}
125
134
  </div>
126
135
  </div>
136
+ </AccentColorProvider>
127
137
  )
128
138
  }
@@ -1,6 +1,8 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { ChevronRight } from 'lucide-react'
3
3
  import { Checkbox } from './checkbox.tsx'
4
+ import { AccentColorProvider, useAccentColor } from '../lib/accent-context.ts'
5
+ import type { FormColor } from '../lib/form-colors.ts'
4
6
 
5
7
  export interface FrontmatterFormHeaderProps {
6
8
  collapsed: boolean
@@ -13,6 +15,7 @@ export interface FrontmatterFormHeaderProps {
13
15
  /** Toggle frontmatter on/off */
14
16
  onFrontmatterToggle?: (enabled: boolean) => void
15
17
  readOnly?: boolean
18
+ accentColor?: FormColor
16
19
  }
17
20
 
18
21
  export function FrontmatterFormHeader({
@@ -23,16 +26,20 @@ export function FrontmatterFormHeader({
23
26
  frontmatterEnabled,
24
27
  onFrontmatterToggle,
25
28
  readOnly,
29
+ accentColor,
26
30
  }: FrontmatterFormHeaderProps) {
31
+ const contextAccent = useAccentColor()
32
+ const effectiveColor = accentColor ?? contextAccent ?? 'blue'
27
33
  const hasFm = frontmatterEnabled !== false
28
34
 
29
35
  return (
30
- <div className="bg-neutral-900 border-b border-neutral-800 select-none">
36
+ <AccentColorProvider value={effectiveColor}>
37
+ <div className="bg-neutral-980 border-b border-neutral-960 select-none">
31
38
  {/* Header bar — always visible, always expandable */}
32
39
  <button
33
40
  type="button"
34
41
  onClick={onToggle}
35
- className="flex items-center gap-2 w-full px-3 py-3 hover:bg-neutral-800/50 cursor-pointer transition-colors"
42
+ className="flex items-center gap-2 w-full px-3 py-3 hover:bg-neutral-960/50 cursor-pointer transition-colors"
36
43
  >
37
44
  <ChevronRight
38
45
  className={`w-3.5 h-3.5 text-neutral-500 transition-transform duration-150 ${
@@ -76,5 +83,6 @@ export function FrontmatterFormHeader({
76
83
  </div>
77
84
  )}
78
85
  </div>
86
+ </AccentColorProvider>
79
87
  )
80
88
  }