skrypt-ai 0.3.3 → 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 (97) 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 +3 -1
  25. package/dist/template/package.json +17 -14
  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 +141 -14
  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 +57 -7
  63. package/dist/template/src/app/not-found.tsx +35 -0
  64. package/dist/template/src/app/page.tsx +95 -11
  65. package/dist/template/src/components/ai-chat.tsx +26 -21
  66. package/dist/template/src/components/breadcrumbs.tsx +56 -12
  67. package/dist/template/src/components/copy-button.tsx +17 -3
  68. package/dist/template/src/components/docs-layout.tsx +202 -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 +56 -20
  72. package/dist/template/src/components/mdx/accordion.tsx +17 -13
  73. package/dist/template/src/components/mdx/callout.tsx +50 -37
  74. package/dist/template/src/components/mdx/card.tsx +24 -12
  75. package/dist/template/src/components/mdx/code-block.tsx +17 -3
  76. package/dist/template/src/components/mdx/code-group.tsx +78 -18
  77. package/dist/template/src/components/mdx/code-playground.tsx +3 -0
  78. package/dist/template/src/components/mdx/go-playground.tsx +3 -0
  79. package/dist/template/src/components/mdx/highlighted-code.tsx +178 -38
  80. package/dist/template/src/components/mdx/python-playground.tsx +2 -0
  81. package/dist/template/src/components/mdx/steps.tsx +6 -6
  82. package/dist/template/src/components/mdx/tabs.tsx +76 -8
  83. package/dist/template/src/components/page-header.tsx +19 -0
  84. package/dist/template/src/components/scroll-to-top.tsx +33 -0
  85. package/dist/template/src/components/search-dialog.tsx +251 -57
  86. package/dist/template/src/components/sidebar.tsx +137 -77
  87. package/dist/template/src/components/table-of-contents.tsx +29 -13
  88. package/dist/template/src/lib/highlight.ts +90 -31
  89. package/dist/template/src/lib/search.ts +14 -4
  90. package/dist/template/src/lib/theme-utils.ts +140 -0
  91. package/dist/template/src/styles/globals.css +397 -84
  92. package/dist/template/src/types/remark-gfm.d.ts +2 -0
  93. package/dist/utils/files.d.ts +9 -0
  94. package/dist/utils/files.js +33 -0
  95. package/dist/utils/validation.d.ts +4 -0
  96. package/dist/utils/validation.js +38 -0
  97. package/package.json +1 -4
@@ -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 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,17 +84,36 @@ 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
- 'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
113
+ 'px-3 py-2 text-[0.8125rem] font-medium transition-colors border-b-2 -mb-px',
57
114
  isActive
58
115
  ? 'border-[var(--color-primary)] text-[var(--color-primary)]'
59
- : 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
116
+ : 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
60
117
  )}
61
118
  >
62
119
  {children}
@@ -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,19 +1,66 @@
1
1
  'use client'
2
2
 
3
- import React, { useEffect, useState, useCallback, useRef } from 'react'
4
- import { Search, X, FileText } 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
12
62
  }
13
63
 
14
- /**
15
- * Highlight search terms in text
16
- */
17
64
  function highlightTerms(text: string, query: string): React.ReactNode {
18
65
  if (!query.trim()) return text
19
66
 
@@ -24,7 +71,7 @@ function highlightTerms(text: string, query: string): React.ReactNode {
24
71
 
25
72
  return parts.map((part, i) =>
26
73
  terms.some(t => part.toLowerCase() === t) ? (
27
- <mark key={i} className="bg-yellow-200 dark:bg-yellow-800 text-inherit rounded px-0.5">
74
+ <mark key={i} className="bg-[var(--color-primary)]/20 text-[var(--color-primary)] rounded px-0.5">
28
75
  {part}
29
76
  </mark>
30
77
  ) : (
@@ -33,18 +80,48 @@ function highlightTerms(text: string, query: string): React.ReactNode {
33
80
  )
34
81
  }
35
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
+
36
104
  export function SearchDialog({ open, onClose }: SearchDialogProps) {
105
+ const router = useRouter()
37
106
  const [query, setQuery] = useState('')
38
107
  const [results, setResults] = useState<SearchResultWithHighlight[]>([])
39
108
  const [isLoading, setIsLoading] = useState(false)
109
+ const [selectedIndex, setSelectedIndex] = useState(0)
110
+ const [recentSearches, setRecentSearches] = useState<string[]>([])
40
111
  const dialogRef = useRef<HTMLDivElement>(null)
41
112
  const inputRef = useRef<HTMLInputElement>(null)
113
+ const selectedRef = useRef<HTMLLIElement>(null)
114
+
115
+ const grouped = useMemo(() => groupResultsBySection(results), [results])
42
116
 
43
- // Focus trap and restore focus on close
44
117
  useEffect(() => {
45
118
  if (open) {
46
119
  const previouslyFocused = document.activeElement as HTMLElement
47
120
  inputRef.current?.focus()
121
+ setQuery('')
122
+ setResults([])
123
+ setSelectedIndex(0)
124
+ setRecentSearches(getRecentSearches())
48
125
 
49
126
  return () => {
50
127
  previouslyFocused?.focus()
@@ -62,6 +139,7 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
62
139
  try {
63
140
  const searchResults = await performSearch(q)
64
141
  setResults(searchResults)
142
+ setSelectedIndex(0)
65
143
  } catch (err) {
66
144
  console.error('Search failed:', err)
67
145
  setResults([])
@@ -71,33 +149,74 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
71
149
  }, [])
72
150
 
73
151
  useEffect(() => {
74
- const debounce = setTimeout(() => search(query), 200)
152
+ const debounce = setTimeout(() => search(query), 150)
75
153
  return () => clearTimeout(debounce)
76
154
  }, [query, search])
77
155
 
78
156
  useEffect(() => {
79
157
  const handleKeyDown = (e: KeyboardEvent) => {
80
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
81
- e.preventDefault()
82
- if (open) onClose()
83
- else onClose() // This should trigger open, but we don't have that control here
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
+
187
+ const handleKeyDown = (e: React.KeyboardEvent) => {
188
+ if (e.key === 'ArrowDown') {
189
+ e.preventDefault()
190
+ setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
191
+ } else if (e.key === 'ArrowUp') {
192
+ e.preventDefault()
193
+ setSelectedIndex((prev) => Math.max(prev - 1, 0))
194
+ } else if (e.key === 'Enter' && results[selectedIndex]) {
195
+ saveRecentSearch(query)
196
+ onClose()
197
+ router.push(results[selectedIndex].href)
198
+ }
199
+ }
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
+
94
213
  if (!open) return null
95
214
 
96
215
  return (
97
216
  <div className="fixed inset-0 z-50" role="presentation">
98
217
  {/* Backdrop */}
99
218
  <div
100
- className="absolute inset-0 bg-black/50 backdrop-blur-sm"
219
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-150"
101
220
  onClick={onClose}
102
221
  aria-hidden="true"
103
222
  />
@@ -108,69 +227,144 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
108
227
  role="dialog"
109
228
  aria-modal="true"
110
229
  aria-label="Search documentation"
111
- className="absolute top-[20%] left-1/2 -translate-x-1/2 w-full max-w-xl"
230
+ className="absolute top-[15%] left-1/2 -translate-x-1/2 w-full max-w-lg px-4 dialog-animate-in"
112
231
  >
113
232
  <div className="bg-[var(--color-bg)] border border-[var(--color-border)] rounded-xl shadow-2xl overflow-hidden">
114
233
  {/* Search input */}
115
234
  <div className="flex items-center gap-3 px-4 border-b border-[var(--color-border)]">
116
- <Search size={20} className="text-[var(--color-text-tertiary)]" />
235
+ <Search size={16} className="text-[var(--color-text-tertiary)] shrink-0" />
117
236
  <input
118
237
  ref={inputRef}
119
238
  type="text"
120
239
  value={query}
121
240
  onChange={(e) => setQuery(e.target.value)}
241
+ onKeyDown={handleKeyDown}
122
242
  placeholder="Search documentation..."
123
- className="flex-1 py-4 bg-transparent outline-none text-[var(--color-text)] placeholder:text-[var(--color-text-tertiary)]"
243
+ className="flex-1 py-3.5 bg-transparent outline-none text-[0.875rem] text-[var(--color-text)] placeholder:text-[var(--color-text-tertiary)]"
124
244
  aria-label="Search query"
125
245
  />
126
- <button
127
- onClick={onClose}
128
- className="p-1 hover:bg-[var(--color-bg-secondary)] rounded"
129
- aria-label="Close search"
130
- >
131
- <X size={20} className="text-[var(--color-text-tertiary)]" />
132
- </button>
246
+ <kbd className="hidden sm:inline px-1.5 py-0.5 text-[0.625rem] font-medium text-[var(--color-text-tertiary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded">
247
+ ESC
248
+ </kbd>
133
249
  </div>
134
250
 
135
251
  {/* Results */}
136
- <div className="max-h-80 overflow-y-auto">
252
+ <div className="max-h-[60vh] overflow-y-auto">
137
253
  {results.length > 0 ? (
138
- <ul className="p-2">
139
- {results.map((result) => (
140
- <li key={result.href}>
141
- <Link
142
- href={result.href}
143
- onClick={onClose}
144
- className="flex items-start gap-3 px-3 py-2 rounded-lg hover:bg-[var(--color-bg-secondary)] transition-colors"
145
- >
146
- <FileText size={20} className="text-[var(--color-text-tertiary)] mt-0.5" />
147
- <div>
148
- <div className="font-medium text-[var(--color-text)]">
149
- {result.title}
150
- </div>
151
- {result.section && (
152
- <div className="text-xs text-[var(--color-text-tertiary)]">
153
- {result.section}
154
- </div>
155
- )}
156
- <div className="text-sm text-[var(--color-text-secondary)] line-clamp-2">
157
- {highlightTerms(result.snippet, query)}
158
- </div>
159
- </div>
160
- </Link>
161
- </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>
162
308
  ))}
163
- </ul>
164
- ) : query ? (
165
- <div className="p-8 text-center text-[var(--color-text-secondary)]">
166
- No results found for "{query}"
167
309
  </div>
168
- ) : (
169
- <div className="p-8 text-center text-[var(--color-text-tertiary)]">
170
- Start typing to search...
310
+ ) : query && !isLoading ? (
311
+ <div className="p-8 text-center text-[0.8125rem] text-[var(--color-text-tertiary)]">
312
+ No results for &ldquo;{query}&rdquo;
171
313
  </div>
172
- )}
314
+ ) : isLoading ? (
315
+ <SearchSkeletons />
316
+ ) : !query ? (
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
+ )
351
+ ) : null}
173
352
  </div>
353
+
354
+ {/* Footer */}
355
+ {results.length > 0 && (
356
+ <div className="flex items-center gap-4 px-4 py-2 border-t border-[var(--color-border)] text-[0.6875rem] text-[var(--color-text-tertiary)]">
357
+ <span className="flex items-center gap-1">
358
+ <kbd className="px-1 py-0.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[0.5625rem]">&uarr;</kbd>
359
+ <kbd className="px-1 py-0.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[0.5625rem]">&darr;</kbd>
360
+ navigate
361
+ </span>
362
+ <span className="flex items-center gap-1">
363
+ <kbd className="px-1 py-0.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[0.5625rem]">&crarr;</kbd>
364
+ open
365
+ </span>
366
+ </div>
367
+ )}
174
368
  </div>
175
369
  </div>
176
370
  </div>