skrypt-ai 0.3.4 → 0.4.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 (95) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/index.d.ts +0 -1
  3. package/dist/auth/index.js +3 -5
  4. package/dist/autofix/index.js +15 -3
  5. package/dist/cli.js +19 -4
  6. package/dist/commands/check-links.js +164 -174
  7. package/dist/commands/deploy.js +5 -2
  8. package/dist/commands/generate.js +206 -199
  9. package/dist/commands/i18n.js +3 -20
  10. package/dist/commands/init.js +47 -40
  11. package/dist/commands/lint.js +3 -20
  12. package/dist/commands/mcp.js +125 -122
  13. package/dist/commands/monitor.js +125 -108
  14. package/dist/commands/review-pr.js +1 -1
  15. package/dist/commands/sdk.js +1 -1
  16. package/dist/config/loader.js +21 -2
  17. package/dist/generator/organizer.d.ts +3 -0
  18. package/dist/generator/organizer.js +4 -9
  19. package/dist/generator/writer.js +2 -10
  20. package/dist/github/pr-comments.js +21 -8
  21. package/dist/plugins/index.js +1 -0
  22. package/dist/scanner/index.js +8 -2
  23. package/dist/template/docs.json +2 -1
  24. package/dist/template/next.config.mjs +2 -1
  25. package/dist/template/package.json +17 -15
  26. package/dist/template/public/favicon.svg +4 -0
  27. package/dist/template/public/search-index.json +1 -1
  28. package/dist/template/scripts/build-search-index.mjs +120 -25
  29. package/dist/template/src/app/api/chat/route.ts +11 -3
  30. package/dist/template/src/app/docs/README.md +28 -0
  31. package/dist/template/src/app/docs/[...slug]/page.tsx +139 -16
  32. package/dist/template/src/app/docs/auth/page.mdx +589 -0
  33. package/dist/template/src/app/docs/autofix/page.mdx +624 -0
  34. package/dist/template/src/app/docs/cli/page.mdx +217 -0
  35. package/dist/template/src/app/docs/config/page.mdx +428 -0
  36. package/dist/template/src/app/docs/configuration/page.mdx +86 -0
  37. package/dist/template/src/app/docs/deployment/page.mdx +112 -0
  38. package/dist/template/src/app/docs/error.tsx +20 -0
  39. package/dist/template/src/app/docs/generator/generator.md +504 -0
  40. package/dist/template/src/app/docs/generator/organizer.md +779 -0
  41. package/dist/template/src/app/docs/generator/page.mdx +613 -0
  42. package/dist/template/src/app/docs/github/page.mdx +502 -0
  43. package/dist/template/src/app/docs/llm/anthropic-client.md +549 -0
  44. package/dist/template/src/app/docs/llm/index.md +471 -0
  45. package/dist/template/src/app/docs/llm/page.mdx +428 -0
  46. package/dist/template/src/app/docs/llms-full.md +256 -0
  47. package/dist/template/src/app/docs/llms.txt +2971 -0
  48. package/dist/template/src/app/docs/not-found.tsx +23 -0
  49. package/dist/template/src/app/docs/page.mdx +0 -3
  50. package/dist/template/src/app/docs/plugins/page.mdx +1793 -0
  51. package/dist/template/src/app/docs/pro/page.mdx +121 -0
  52. package/dist/template/src/app/docs/quickstart/page.mdx +93 -0
  53. package/dist/template/src/app/docs/scanner/content-type.md +599 -0
  54. package/dist/template/src/app/docs/scanner/index.md +212 -0
  55. package/dist/template/src/app/docs/scanner/page.mdx +307 -0
  56. package/dist/template/src/app/docs/scanner/python.md +469 -0
  57. package/dist/template/src/app/docs/scanner/python_parser.md +1056 -0
  58. package/dist/template/src/app/docs/scanner/rust.md +325 -0
  59. package/dist/template/src/app/docs/scanner/typescript.md +201 -0
  60. package/dist/template/src/app/error.tsx +3 -3
  61. package/dist/template/src/app/icon.tsx +29 -0
  62. package/dist/template/src/app/layout.tsx +42 -0
  63. package/dist/template/src/app/not-found.tsx +35 -0
  64. package/dist/template/src/app/page.tsx +62 -28
  65. package/dist/template/src/components/ai-chat.tsx +26 -21
  66. package/dist/template/src/components/breadcrumbs.tsx +46 -2
  67. package/dist/template/src/components/copy-button.tsx +17 -3
  68. package/dist/template/src/components/docs-layout.tsx +142 -8
  69. package/dist/template/src/components/feedback.tsx +4 -2
  70. package/dist/template/src/components/footer.tsx +42 -0
  71. package/dist/template/src/components/header.tsx +29 -5
  72. package/dist/template/src/components/mdx/accordion.tsx +7 -6
  73. package/dist/template/src/components/mdx/card.tsx +19 -7
  74. package/dist/template/src/components/mdx/code-block.tsx +17 -3
  75. package/dist/template/src/components/mdx/code-group.tsx +65 -18
  76. package/dist/template/src/components/mdx/code-playground.tsx +3 -0
  77. package/dist/template/src/components/mdx/go-playground.tsx +3 -0
  78. package/dist/template/src/components/mdx/highlighted-code.tsx +171 -76
  79. package/dist/template/src/components/mdx/python-playground.tsx +2 -0
  80. package/dist/template/src/components/mdx/tabs.tsx +74 -6
  81. package/dist/template/src/components/page-header.tsx +19 -0
  82. package/dist/template/src/components/scroll-to-top.tsx +33 -0
  83. package/dist/template/src/components/search-dialog.tsx +206 -52
  84. package/dist/template/src/components/sidebar.tsx +136 -77
  85. package/dist/template/src/components/table-of-contents.tsx +23 -7
  86. package/dist/template/src/lib/highlight.ts +90 -31
  87. package/dist/template/src/lib/search.ts +14 -4
  88. package/dist/template/src/lib/theme-utils.ts +140 -0
  89. package/dist/template/src/styles/globals.css +307 -166
  90. package/dist/template/src/types/remark-gfm.d.ts +2 -0
  91. package/dist/utils/files.d.ts +9 -0
  92. package/dist/utils/files.js +33 -0
  93. package/dist/utils/validation.d.ts +4 -0
  94. package/dist/utils/validation.js +38 -0
  95. package/package.json +1 -4
@@ -212,6 +212,7 @@ sys.stderr = _capture.stderr
212
212
  onClick={handleReset}
213
213
  className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text)] rounded"
214
214
  title="Reset code"
215
+ aria-label="Reset code"
215
216
  >
216
217
  <RotateCcw size={14} />
217
218
  </button>
@@ -219,6 +220,7 @@ sys.stderr = _capture.stderr
219
220
  onClick={handleDownload}
220
221
  className="p-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text)] rounded"
221
222
  title="Download code"
223
+ aria-label="Download code"
222
224
  >
223
225
  <Download size={14} />
224
226
  </button>
@@ -1,11 +1,14 @@
1
1
  'use client'
2
2
 
3
- import { createContext, useContext, useState, ReactNode } from 'react'
3
+ import { createContext, useContext, useState, useRef, useCallback, useId, ReactNode } from 'react'
4
4
  import { cn } from '@/lib/utils'
5
5
 
6
6
  interface TabsContextValue {
7
7
  activeTab: string
8
8
  setActiveTab: (tab: string) => void
9
+ instanceId: string
10
+ tabRefs: React.MutableRefObject<Map<string, HTMLButtonElement>>
11
+ tabValues: string[]
9
12
  }
10
13
 
11
14
  const TabsContext = createContext<TabsContextValue | null>(null)
@@ -17,9 +20,13 @@ interface TabsProps {
17
20
 
18
21
  export function Tabs({ defaultValue, children }: TabsProps) {
19
22
  const [activeTab, setActiveTab] = useState(defaultValue)
23
+ const instanceId = useId()
24
+ const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
25
+ // Collect tab values from children to enable arrow key navigation
26
+ const tabValues = useRef<string[]>([])
20
27
 
21
28
  return (
22
- <TabsContext.Provider value={{ activeTab, setActiveTab }}>
29
+ <TabsContext.Provider value={{ activeTab, setActiveTab, instanceId, tabRefs, tabValues: tabValues.current }}>
23
30
  <div className="my-6">{children}</div>
24
31
  </TabsContext.Provider>
25
32
  )
@@ -30,8 +37,39 @@ interface TabListProps {
30
37
  }
31
38
 
32
39
  export function TabList({ children }: TabListProps) {
40
+ const context = useContext(TabsContext)
41
+ if (!context) throw new Error('TabList must be used within Tabs')
42
+
43
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
44
+ const { tabValues, tabRefs, activeTab, setActiveTab } = context
45
+ if (!tabValues.length) return
46
+
47
+ const currentIndex = tabValues.indexOf(activeTab)
48
+ let nextIndex = -1
49
+
50
+ if (e.key === 'ArrowRight') {
51
+ e.preventDefault()
52
+ nextIndex = (currentIndex + 1) % tabValues.length
53
+ } else if (e.key === 'ArrowLeft') {
54
+ e.preventDefault()
55
+ nextIndex = (currentIndex - 1 + tabValues.length) % tabValues.length
56
+ } else if (e.key === 'Home') {
57
+ e.preventDefault()
58
+ nextIndex = 0
59
+ } else if (e.key === 'End') {
60
+ e.preventDefault()
61
+ nextIndex = tabValues.length - 1
62
+ }
63
+
64
+ if (nextIndex >= 0) {
65
+ const nextValue = tabValues[nextIndex]
66
+ setActiveTab(nextValue)
67
+ tabRefs.current.get(nextValue)?.focus()
68
+ }
69
+ }, [context])
70
+
33
71
  return (
34
- <div className="flex gap-0.5 border-b border-[var(--color-border)]">
72
+ <div role="tablist" className="flex gap-0.5 border-b border-[var(--color-border)]" onKeyDown={handleKeyDown}>
35
73
  {children}
36
74
  </div>
37
75
  )
@@ -46,11 +84,30 @@ export function Tab({ value, children }: TabProps) {
46
84
  const context = useContext(TabsContext)
47
85
  if (!context) throw new Error('Tab must be used within Tabs')
48
86
 
49
- const { activeTab, setActiveTab } = context
87
+ const { activeTab, setActiveTab, instanceId, tabRefs, tabValues } = context
50
88
  const isActive = activeTab === value
51
89
 
90
+ // Register this tab value for arrow key navigation
91
+ const refCallback = useCallback((el: HTMLButtonElement | null) => {
92
+ if (el) {
93
+ tabRefs.current.set(value, el)
94
+ if (!tabValues.includes(value)) {
95
+ tabValues.push(value)
96
+ }
97
+ }
98
+ }, [value, tabRefs, tabValues])
99
+
100
+ const tabId = `tab-${instanceId}-${value}`
101
+ const panelId = `panel-${instanceId}-${value}`
102
+
52
103
  return (
53
104
  <button
105
+ ref={refCallback}
106
+ id={tabId}
107
+ role="tab"
108
+ aria-selected={isActive}
109
+ aria-controls={panelId}
110
+ tabIndex={isActive ? 0 : -1}
54
111
  onClick={() => setActiveTab(value)}
55
112
  className={cn(
56
113
  'px-3 py-2 text-[0.8125rem] font-medium transition-colors border-b-2 -mb-px',
@@ -73,9 +130,20 @@ export function TabPanel({ value, children }: TabPanelProps) {
73
130
  const context = useContext(TabsContext)
74
131
  if (!context) throw new Error('TabPanel must be used within Tabs')
75
132
 
76
- const { activeTab } = context
133
+ const { activeTab, instanceId } = context
134
+ const tabId = `tab-${instanceId}-${value}`
135
+ const panelId = `panel-${instanceId}-${value}`
77
136
 
78
137
  if (activeTab !== value) return null
79
138
 
80
- return <div className="pt-4">{children}</div>
139
+ return (
140
+ <div
141
+ id={panelId}
142
+ role="tabpanel"
143
+ aria-labelledby={tabId}
144
+ className="pt-4"
145
+ >
146
+ {children}
147
+ </div>
148
+ )
81
149
  }
@@ -0,0 +1,19 @@
1
+ interface PageHeaderProps {
2
+ title: string
3
+ description?: string
4
+ }
5
+
6
+ export function PageHeader({ title, description }: PageHeaderProps) {
7
+ return (
8
+ <div className="mb-8 pb-6 border-b border-[var(--color-border)]">
9
+ <h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-[var(--color-text)]">
10
+ {title}
11
+ </h1>
12
+ {description && (
13
+ <p className="text-lg text-[var(--color-text-secondary)] mt-3 leading-relaxed max-w-2xl">
14
+ {description}
15
+ </p>
16
+ )}
17
+ </div>
18
+ )
19
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { ChevronUp } from 'lucide-react'
5
+
6
+ export function ScrollToTop() {
7
+ const [visible, setVisible] = useState(false)
8
+
9
+ useEffect(() => {
10
+ function handleScroll() {
11
+ setVisible(window.scrollY > 300)
12
+ }
13
+
14
+ window.addEventListener('scroll', handleScroll, { passive: true })
15
+ return () => window.removeEventListener('scroll', handleScroll)
16
+ }, [])
17
+
18
+ function scrollToTop() {
19
+ window.scrollTo({ top: 0, behavior: 'smooth' })
20
+ }
21
+
22
+ if (!visible) return null
23
+
24
+ return (
25
+ <button
26
+ onClick={scrollToTop}
27
+ className="fixed bottom-6 right-24 z-40 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-lg transition-all hover:bg-[var(--color-primary-dark)] hover:scale-105"
28
+ aria-label="Scroll to top"
29
+ >
30
+ <ChevronUp size={20} />
31
+ </button>
32
+ )
33
+ }
@@ -1,11 +1,61 @@
1
1
  'use client'
2
2
 
3
- import React, { useEffect, useState, useCallback, useRef } from 'react'
4
- import { Search, FileText, ArrowRight } from 'lucide-react'
3
+ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
4
+ import { Search, FileText, ArrowRight, Clock, X } from 'lucide-react'
5
5
  import Link from 'next/link'
6
+ import { useRouter } from 'next/navigation'
6
7
  import { cn } from '@/lib/utils'
7
8
  import { search as performSearch, SearchResultWithHighlight } from '@/lib/search'
8
9
 
10
+ const RECENT_SEARCHES_KEY = 'recent-searches'
11
+ const MAX_RECENT_SEARCHES = 5
12
+
13
+ function getRecentSearches(): string[] {
14
+ try {
15
+ const stored = localStorage.getItem(RECENT_SEARCHES_KEY)
16
+ return stored ? JSON.parse(stored) : []
17
+ } catch {
18
+ return []
19
+ }
20
+ }
21
+
22
+ function saveRecentSearch(query: string) {
23
+ try {
24
+ const trimmed = query.trim()
25
+ if (!trimmed) return
26
+ const existing = getRecentSearches()
27
+ const filtered = existing.filter((s) => s !== trimmed)
28
+ const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES)
29
+ localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated))
30
+ } catch {
31
+ // localStorage unavailable
32
+ }
33
+ }
34
+
35
+ function clearRecentSearches() {
36
+ try {
37
+ localStorage.removeItem(RECENT_SEARCHES_KEY)
38
+ } catch {
39
+ // localStorage unavailable
40
+ }
41
+ }
42
+
43
+ function SearchSkeletons() {
44
+ return (
45
+ <div className="p-4 space-y-4">
46
+ {[0, 1, 2, 3].map((i) => (
47
+ <div key={i} className="flex items-center gap-3">
48
+ <div className="skeleton w-8 h-8 rounded-full shrink-0" />
49
+ <div className="flex-1 space-y-2">
50
+ <div className="skeleton h-3.5 w-3/4" />
51
+ <div className="skeleton h-3 w-1/2" />
52
+ </div>
53
+ </div>
54
+ ))}
55
+ </div>
56
+ )
57
+ }
58
+
9
59
  interface SearchDialogProps {
10
60
  open: boolean
11
61
  onClose: () => void
@@ -30,13 +80,39 @@ function highlightTerms(text: string, query: string): React.ReactNode {
30
80
  )
31
81
  }
32
82
 
83
+ interface GroupedResults {
84
+ section: string
85
+ results: SearchResultWithHighlight[]
86
+ }
87
+
88
+ function groupResultsBySection(results: SearchResultWithHighlight[]): GroupedResults[] {
89
+ const map = new Map<string, SearchResultWithHighlight[]>()
90
+ const order: string[] = []
91
+
92
+ for (const result of results) {
93
+ const section = result.section || 'Results'
94
+ if (!map.has(section)) {
95
+ map.set(section, [])
96
+ order.push(section)
97
+ }
98
+ map.get(section)!.push(result)
99
+ }
100
+
101
+ return order.map(section => ({ section, results: map.get(section)! }))
102
+ }
103
+
33
104
  export function SearchDialog({ open, onClose }: SearchDialogProps) {
105
+ const router = useRouter()
34
106
  const [query, setQuery] = useState('')
35
107
  const [results, setResults] = useState<SearchResultWithHighlight[]>([])
36
108
  const [isLoading, setIsLoading] = useState(false)
37
109
  const [selectedIndex, setSelectedIndex] = useState(0)
110
+ const [recentSearches, setRecentSearches] = useState<string[]>([])
38
111
  const dialogRef = useRef<HTMLDivElement>(null)
39
112
  const inputRef = useRef<HTMLInputElement>(null)
113
+ const selectedRef = useRef<HTMLLIElement>(null)
114
+
115
+ const grouped = useMemo(() => groupResultsBySection(results), [results])
40
116
 
41
117
  useEffect(() => {
42
118
  if (open) {
@@ -45,6 +121,7 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
45
121
  setQuery('')
46
122
  setResults([])
47
123
  setSelectedIndex(0)
124
+ setRecentSearches(getRecentSearches())
48
125
 
49
126
  return () => {
50
127
  previouslyFocused?.focus()
@@ -78,19 +155,35 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
78
155
 
79
156
  useEffect(() => {
80
157
  const handleKeyDown = (e: KeyboardEvent) => {
81
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
82
- e.preventDefault()
83
- if (open) onClose()
84
- }
85
158
  if (e.key === 'Escape' && open) {
86
159
  onClose()
87
160
  }
161
+ // Focus trapping within the dialog
162
+ if (e.key === 'Tab' && open && dialogRef.current) {
163
+ const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
164
+ 'input, button, a[href], [tabindex]:not([tabindex="-1"])'
165
+ )
166
+ if (focusable.length === 0) return
167
+ const first = focusable[0]
168
+ const last = focusable[focusable.length - 1]
169
+ if (e.shiftKey && document.activeElement === first) {
170
+ e.preventDefault()
171
+ last.focus()
172
+ } else if (!e.shiftKey && document.activeElement === last) {
173
+ e.preventDefault()
174
+ first.focus()
175
+ }
176
+ }
88
177
  }
89
178
 
90
179
  document.addEventListener('keydown', handleKeyDown)
91
180
  return () => document.removeEventListener('keydown', handleKeyDown)
92
181
  }, [open, onClose])
93
182
 
183
+ useEffect(() => {
184
+ selectedRef.current?.scrollIntoView({ block: 'nearest' })
185
+ }, [selectedIndex])
186
+
94
187
  const handleKeyDown = (e: React.KeyboardEvent) => {
95
188
  if (e.key === 'ArrowDown') {
96
189
  e.preventDefault()
@@ -99,18 +192,31 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
99
192
  e.preventDefault()
100
193
  setSelectedIndex((prev) => Math.max(prev - 1, 0))
101
194
  } else if (e.key === 'Enter' && results[selectedIndex]) {
195
+ saveRecentSearch(query)
102
196
  onClose()
103
- window.location.href = results[selectedIndex].href
197
+ router.push(results[selectedIndex].href)
104
198
  }
105
199
  }
106
200
 
201
+ // Build a flat index map for grouped results
202
+ const flatIndexMap = useMemo(() => {
203
+ const map = new Map<string, number>()
204
+ let idx = 0
205
+ for (const group of grouped) {
206
+ for (const result of group.results) {
207
+ map.set(result.href, idx++)
208
+ }
209
+ }
210
+ return map
211
+ }, [grouped])
212
+
107
213
  if (!open) return null
108
214
 
109
215
  return (
110
216
  <div className="fixed inset-0 z-50" role="presentation">
111
217
  {/* Backdrop */}
112
218
  <div
113
- className="absolute inset-0 bg-black/60 backdrop-blur-sm"
219
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-150"
114
220
  onClick={onClose}
115
221
  aria-hidden="true"
116
222
  />
@@ -121,7 +227,7 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
121
227
  role="dialog"
122
228
  aria-modal="true"
123
229
  aria-label="Search documentation"
124
- className="absolute top-[15%] left-1/2 -translate-x-1/2 w-full max-w-lg px-4"
230
+ className="absolute top-[15%] left-1/2 -translate-x-1/2 w-full max-w-lg px-4 dialog-animate-in"
125
231
  >
126
232
  <div className="bg-[var(--color-bg)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden">
127
233
  {/* Search input */}
@@ -145,55 +251,103 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
145
251
  {/* Results */}
146
252
  <div className="max-h-[60vh] overflow-y-auto">
147
253
  {results.length > 0 ? (
148
- <ul className="p-1.5">
149
- {results.map((result, index) => (
150
- <li key={result.href}>
151
- <Link
152
- href={result.href}
153
- onClick={onClose}
154
- className={cn(
155
- 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors hover:no-underline group',
156
- index === selectedIndex
157
- ? 'bg-[var(--color-primary)] text-white'
158
- : 'hover:bg-[var(--color-bg-secondary)]'
159
- )}
160
- >
161
- <FileText size={16} className={cn(
162
- 'shrink-0',
163
- index === selectedIndex ? 'text-white/70' : 'text-[var(--color-text-tertiary)]'
164
- )} />
165
- <div className="flex-1 min-w-0">
166
- <div className={cn(
167
- 'text-[0.8125rem] font-medium truncate',
168
- index === selectedIndex ? 'text-white' : 'text-[var(--color-text)]'
169
- )}>
170
- {result.title}
171
- </div>
172
- {result.snippet && (
173
- <div className={cn(
174
- 'text-[0.75rem] truncate mt-0.5',
175
- index === selectedIndex ? 'text-white/70' : 'text-[var(--color-text-tertiary)]'
176
- )}>
177
- {index === selectedIndex ? result.snippet : highlightTerms(result.snippet, query)}
178
- </div>
179
- )}
180
- </div>
181
- <ArrowRight size={14} className={cn(
182
- 'shrink-0 opacity-0 group-hover:opacity-100 transition-opacity',
183
- index === selectedIndex ? 'text-white/70 opacity-100' : 'text-[var(--color-text-tertiary)]'
184
- )} />
185
- </Link>
186
- </li>
254
+ <div className="p-1.5">
255
+ {grouped.map((group) => (
256
+ <div key={group.section}>
257
+ {/* Section header */}
258
+ <div className="px-3 pt-3 pb-1.5">
259
+ <span className="text-[0.6875rem] font-bold uppercase tracking-wider text-[var(--color-text-tertiary)]">
260
+ {group.section}
261
+ </span>
262
+ </div>
263
+ <ul>
264
+ {group.results.map((result) => {
265
+ const index = flatIndexMap.get(result.href) ?? 0
266
+ return (
267
+ <li key={result.href} ref={index === selectedIndex ? selectedRef : undefined}>
268
+ <Link
269
+ href={result.href}
270
+ onClick={() => { saveRecentSearch(query); onClose() }}
271
+ className={cn(
272
+ 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors hover:no-underline group',
273
+ index === selectedIndex
274
+ ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
275
+ : 'hover:bg-[var(--color-bg-secondary)]'
276
+ )}
277
+ >
278
+ <FileText size={16} className={cn(
279
+ 'shrink-0',
280
+ index === selectedIndex ? 'text-white/70' : 'text-[var(--color-text-tertiary)]'
281
+ )} />
282
+ <div className="flex-1 min-w-0">
283
+ <div className={cn(
284
+ 'text-[0.8125rem] font-medium truncate',
285
+ index === selectedIndex ? 'text-white' : 'text-[var(--color-text)]'
286
+ )}>
287
+ {result.title}
288
+ </div>
289
+ {result.snippet && (
290
+ <div className={cn(
291
+ 'text-[0.75rem] truncate mt-0.5',
292
+ index === selectedIndex ? 'text-white/70' : 'text-[var(--color-text-tertiary)]'
293
+ )}>
294
+ {index === selectedIndex ? result.snippet : highlightTerms(result.snippet, query)}
295
+ </div>
296
+ )}
297
+ </div>
298
+ <ArrowRight size={14} className={cn(
299
+ 'shrink-0 opacity-0 group-hover:opacity-100 transition-opacity',
300
+ index === selectedIndex ? 'text-white/70 opacity-100' : 'text-[var(--color-text-tertiary)]'
301
+ )} />
302
+ </Link>
303
+ </li>
304
+ )
305
+ })}
306
+ </ul>
307
+ </div>
187
308
  ))}
188
- </ul>
309
+ </div>
189
310
  ) : query && !isLoading ? (
190
311
  <div className="p-8 text-center text-[0.8125rem] text-[var(--color-text-tertiary)]">
191
312
  No results for &ldquo;{query}&rdquo;
192
313
  </div>
314
+ ) : isLoading ? (
315
+ <SearchSkeletons />
193
316
  ) : !query ? (
194
- <div className="p-6 text-center text-[0.8125rem] text-[var(--color-text-tertiary)]">
195
- Type to search the docs
196
- </div>
317
+ recentSearches.length > 0 ? (
318
+ <div className="p-1.5">
319
+ <div className="px-3 pt-3 pb-1.5 flex items-center justify-between">
320
+ <span className="text-[0.6875rem] font-bold uppercase tracking-wider text-[var(--color-text-tertiary)]">
321
+ Recent
322
+ </span>
323
+ <button
324
+ onClick={() => { clearRecentSearches(); setRecentSearches([]) }}
325
+ className="text-[0.6875rem] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
326
+ >
327
+ Clear
328
+ </button>
329
+ </div>
330
+ <ul>
331
+ {recentSearches.map((recentQuery) => (
332
+ <li key={recentQuery}>
333
+ <button
334
+ onClick={() => setQuery(recentQuery)}
335
+ className="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors hover:bg-[var(--color-bg-secondary)] w-full text-left"
336
+ >
337
+ <Clock size={14} className="text-[var(--color-text-tertiary)] shrink-0" />
338
+ <span className="text-[0.8125rem] text-[var(--color-text-secondary)] truncate">
339
+ {recentQuery}
340
+ </span>
341
+ </button>
342
+ </li>
343
+ ))}
344
+ </ul>
345
+ </div>
346
+ ) : (
347
+ <div className="p-6 text-center text-[0.8125rem] text-[var(--color-text-tertiary)]">
348
+ Type to search the docs
349
+ </div>
350
+ )
197
351
  ) : null}
198
352
  </div>
199
353