skrypt-ai 0.4.0 → 0.4.2

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.
@@ -36,7 +36,7 @@ function parseConfigFile(filepath) {
36
36
  }
37
37
  catch (err) {
38
38
  throw new Error(`Could not read config file: ${filepath}. ` +
39
- (err instanceof Error ? err.message : String(err)));
39
+ (err instanceof Error ? err.message : String(err)), { cause: err });
40
40
  }
41
41
  let parsed;
42
42
  try {
@@ -45,7 +45,7 @@ function parseConfigFile(filepath) {
45
45
  catch (err) {
46
46
  throw new Error(`Config file has invalid YAML: ${filepath}. ` +
47
47
  `Please check the syntax and try again. ` +
48
- (err instanceof Error ? err.message : String(err)));
48
+ (err instanceof Error ? err.message : String(err)), { cause: err });
49
49
  }
50
50
  if (parsed === null || parsed === undefined || typeof parsed !== 'object') {
51
51
  throw new Error(`Config file is empty or not a valid YAML object: ${filepath}. ` +
@@ -0,0 +1,56 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ interface DarkImageProps {
7
+ src: string
8
+ darkSrc: string
9
+ alt: string
10
+ width?: number
11
+ height?: number
12
+ className?: string
13
+ }
14
+
15
+ function getIsDark(): boolean {
16
+ if (typeof document === 'undefined') return false
17
+ if (document.documentElement.classList.contains('dark')) return true
18
+ if (document.documentElement.classList.contains('light')) return false
19
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
20
+ }
21
+
22
+ export function DarkImage({ src, darkSrc, alt, width, height, className }: DarkImageProps) {
23
+ const [isDark, setIsDark] = useState(false)
24
+
25
+ useEffect(() => {
26
+ setIsDark(getIsDark())
27
+
28
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
29
+ const onMediaChange = () => setIsDark(getIsDark())
30
+ mq.addEventListener('change', onMediaChange)
31
+
32
+ const observer = new MutationObserver(() => {
33
+ setIsDark(getIsDark())
34
+ })
35
+
36
+ observer.observe(document.documentElement, {
37
+ attributes: true,
38
+ attributeFilter: ['class'],
39
+ })
40
+
41
+ return () => {
42
+ mq.removeEventListener('change', onMediaChange)
43
+ observer.disconnect()
44
+ }
45
+ }, [])
46
+
47
+ return (
48
+ <img
49
+ src={isDark ? darkSrc : src}
50
+ alt={alt}
51
+ width={width}
52
+ height={height}
53
+ className={cn('rounded-lg', className)}
54
+ />
55
+ )
56
+ }
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { cn } from '@/lib/utils'
4
+
5
+ interface FrameProps {
6
+ children: React.ReactNode
7
+ caption?: string
8
+ variant?: 'browser' | 'terminal' | 'plain'
9
+ }
10
+
11
+ function TrafficDots() {
12
+ return (
13
+ <div className="flex items-center gap-1.5">
14
+ <span className="block w-2 h-2 rounded-full bg-[#ff5f57]" />
15
+ <span className="block w-2 h-2 rounded-full bg-[#febc2e]" />
16
+ <span className="block w-2 h-2 rounded-full bg-[#28c840]" />
17
+ </div>
18
+ )
19
+ }
20
+
21
+ function BrowserChrome() {
22
+ return (
23
+ <div className="flex items-center gap-3 px-3.5 py-2.5 border-b border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
24
+ <TrafficDots />
25
+ <div className="flex-1 h-5 rounded-md bg-[var(--color-bg)] border border-[var(--color-border)] px-2.5 flex items-center">
26
+ <span className="text-[10px] text-[var(--color-text-tertiary)] select-none truncate">
27
+ https://example.com
28
+ </span>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ function TerminalChrome() {
35
+ return (
36
+ <div className="flex items-center gap-3 px-3.5 py-2.5 bg-[#1a1a1a]">
37
+ <TrafficDots />
38
+ </div>
39
+ )
40
+ }
41
+
42
+ export function Frame({ children, caption, variant = 'browser' }: FrameProps) {
43
+ return (
44
+ <figure className="my-4">
45
+ <div
46
+ className={cn(
47
+ 'rounded-xl border border-[var(--color-border)] overflow-hidden',
48
+ variant === 'terminal' && 'bg-[#1a1a1a]'
49
+ )}
50
+ >
51
+ {variant === 'browser' && <BrowserChrome />}
52
+ {variant === 'terminal' && <TerminalChrome />}
53
+ <div className="[&>img]:!m-0 [&>img]:!rounded-none [&>img]:block [&>img]:w-full">
54
+ {children}
55
+ </div>
56
+ </div>
57
+ {caption && (
58
+ <figcaption className="text-sm text-[var(--color-text-tertiary)] mt-2 text-center">
59
+ {caption}
60
+ </figcaption>
61
+ )}
62
+ </figure>
63
+ )
64
+ }
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState, useEffect, useContext, ReactNode, isValidElement, Children, useRef } from 'react'
3
+ import { useState, useEffect, useContext, ReactNode, isValidElement, Children, useRef, useCallback } from 'react'
4
4
  import { Copy, Check } from 'lucide-react'
5
5
  import { highlight, DEFAULT_THEME, isLightTheme, type ThemeName } from '@/lib/highlight'
6
6
  import { SyntaxThemeContext } from '@/contexts/syntax-theme'
@@ -54,11 +54,56 @@ function parseFilename(meta: string): string | undefined {
54
54
  return match ? match[1] : undefined
55
55
  }
56
56
 
57
+ /** Hook to track horizontal overflow and scroll position of a scrollable element */
58
+ function useScrollOverflow(ref: React.RefObject<HTMLElement | null>) {
59
+ const [hasOverflow, setHasOverflow] = useState(false)
60
+ const [scrolledToEnd, setScrolledToEnd] = useState(false)
61
+ const [scrolledFromStart, setScrolledFromStart] = useState(false)
62
+
63
+ const checkOverflow = useCallback(() => {
64
+ const el = ref.current
65
+ if (!el) return
66
+ const overflows = el.scrollWidth > el.clientWidth + 1
67
+ setHasOverflow(overflows)
68
+ if (overflows) {
69
+ const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1
70
+ const atStart = el.scrollLeft <= 1
71
+ setScrolledToEnd(atEnd)
72
+ setScrolledFromStart(!atStart)
73
+ } else {
74
+ setScrolledToEnd(false)
75
+ setScrolledFromStart(false)
76
+ }
77
+ }, [ref])
78
+
79
+ useEffect(() => {
80
+ const el = ref.current
81
+ if (!el) return
82
+
83
+ checkOverflow()
84
+
85
+ // Re-check on window resize
86
+ const resizeObserver = new ResizeObserver(checkOverflow)
87
+ resizeObserver.observe(el)
88
+
89
+ el.addEventListener('scroll', checkOverflow, { passive: true })
90
+ return () => {
91
+ resizeObserver.disconnect()
92
+ el.removeEventListener('scroll', checkOverflow)
93
+ }
94
+ }, [ref, checkOverflow])
95
+
96
+ return { hasOverflow, scrolledToEnd, scrolledFromStart }
97
+ }
98
+
57
99
  export function HighlightedCode({ children, className }: HighlightedCodeProps) {
58
100
  const [copied, setCopied] = useState(false)
59
101
  const [showToast, setShowToast] = useState(false)
60
102
  const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
61
103
  const copyButtonRef = useRef<HTMLButtonElement>(null)
104
+ const scrollRef = useRef<HTMLDivElement>(null)
105
+
106
+ const { hasOverflow, scrolledToEnd, scrolledFromStart } = useScrollOverflow(scrollRef)
62
107
 
63
108
  const syntaxContext = useContext(SyntaxThemeContext)
64
109
  const theme: ThemeName = syntaxContext?.theme ?? DEFAULT_THEME
@@ -99,6 +144,17 @@ export function HighlightedCode({ children, className }: HighlightedCodeProps) {
99
144
  return () => { cancelled = true }
100
145
  }, [codeText, language, theme])
101
146
 
147
+ // Re-check overflow after highlight finishes (content may change width)
148
+ useEffect(() => {
149
+ if (highlightedHtml && scrollRef.current) {
150
+ const el = scrollRef.current
151
+ // Slight delay to let DOM paint
152
+ requestAnimationFrame(() => {
153
+ el.dispatchEvent(new Event('scroll'))
154
+ })
155
+ }
156
+ }, [highlightedHtml])
157
+
102
158
  async function handleCopy() {
103
159
  try {
104
160
  await navigator.clipboard.writeText(codeText)
@@ -126,51 +182,109 @@ export function HighlightedCode({ children, className }: HighlightedCodeProps) {
126
182
  // Remove trailing empty line from code
127
183
  if (lines[lines.length - 1] === '') lines.pop()
128
184
 
185
+ const fadeGradient = isLight
186
+ ? 'linear-gradient(to right, transparent, var(--color-code-bg))'
187
+ : 'linear-gradient(to right, transparent, var(--color-code-bg))'
188
+ const fadeGradientLeft = isLight
189
+ ? 'linear-gradient(to left, transparent, var(--color-code-bg))'
190
+ : 'linear-gradient(to left, transparent, var(--color-code-bg))'
191
+
129
192
  /** Render code with line numbers and/or line highlighting */
130
193
  function renderWithLineFeatures(htmlContent: string | null) {
131
194
  if (!hasLineFeatures) {
132
195
  // No line features — render normally
133
196
  if (htmlContent) {
134
197
  return (
135
- <div
136
- className="[&>pre]:!rounded-none [&>pre]:!border-0 [&>pre]:!m-0 [&>pre]:py-3.5 [&>pre]:px-4 [&>pre]:overflow-x-auto"
137
- dangerouslySetInnerHTML={{ __html: htmlContent }}
138
- />
198
+ <div className="relative">
199
+ <div
200
+ ref={scrollRef}
201
+ className="[&>pre]:!rounded-none [&>pre]:!border-0 [&>pre]:!m-0 [&>pre]:py-3.5 [&>pre]:px-4 [&>pre]:overflow-x-auto"
202
+ dangerouslySetInnerHTML={{ __html: htmlContent }}
203
+ />
204
+ {hasOverflow && scrolledFromStart && (
205
+ <div
206
+ className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none"
207
+ style={{ background: fadeGradientLeft }}
208
+ aria-hidden="true"
209
+ />
210
+ )}
211
+ {hasOverflow && !scrolledToEnd && (
212
+ <div
213
+ className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none"
214
+ style={{ background: fadeGradient }}
215
+ aria-hidden="true"
216
+ />
217
+ )}
218
+ </div>
139
219
  )
140
220
  }
141
221
  return (
142
- <pre className="!rounded-none !border-0 !m-0 py-3.5 px-4 overflow-x-auto">
143
- {children}
144
- </pre>
222
+ <div className="relative">
223
+ <div ref={scrollRef}>
224
+ <pre className="!rounded-none !border-0 !m-0 py-3.5 px-4 overflow-x-auto">
225
+ {children}
226
+ </pre>
227
+ </div>
228
+ {hasOverflow && scrolledFromStart && (
229
+ <div
230
+ className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none"
231
+ style={{ background: fadeGradientLeft }}
232
+ aria-hidden="true"
233
+ />
234
+ )}
235
+ {hasOverflow && !scrolledToEnd && (
236
+ <div
237
+ className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none"
238
+ style={{ background: fadeGradient }}
239
+ aria-hidden="true"
240
+ />
241
+ )}
242
+ </div>
145
243
  )
146
244
  }
147
245
 
148
246
  // With line features, we need to render line by line
149
247
  return (
150
- <div className="overflow-x-auto">
151
- <table className="w-full border-collapse" style={{ tableLayout: 'auto' }}>
152
- <tbody>
153
- {lines.map((line, i) => {
154
- const lineNum = i + 1
155
- const isHighlighted = highlightedLines.has(lineNum)
156
- return (
157
- <tr
158
- key={i}
159
- className={isHighlighted ? 'bg-[var(--color-primary)]/[0.07]' : ''}
160
- >
161
- {showLineNumbers && (
162
- <td className="text-right pr-4 select-none text-[var(--color-text-tertiary)] text-[0.75rem] py-0 pl-4 w-[1%] whitespace-nowrap align-top leading-[1.7142857]">
163
- {lineNum}
248
+ <div className="relative">
249
+ <div ref={scrollRef} className="overflow-x-auto">
250
+ <table className="w-full border-collapse" style={{ tableLayout: 'auto' }}>
251
+ <tbody>
252
+ {lines.map((line, i) => {
253
+ const lineNum = i + 1
254
+ const isHighlighted = highlightedLines.has(lineNum)
255
+ return (
256
+ <tr
257
+ key={i}
258
+ className={isHighlighted ? 'bg-[var(--color-primary)]/[0.07]' : ''}
259
+ >
260
+ {showLineNumbers && (
261
+ <td className="text-right pr-4 select-none text-[var(--color-text-tertiary)] text-[0.75rem] py-0 pl-4 w-[1%] whitespace-nowrap align-top leading-[1.7142857]">
262
+ {lineNum}
263
+ </td>
264
+ )}
265
+ <td className="py-0 pr-4 whitespace-pre font-mono text-[0.875em] leading-[1.7142857]">
266
+ {line || ' '}
164
267
  </td>
165
- )}
166
- <td className="py-0 pr-4 whitespace-pre font-mono text-[0.875em] leading-[1.7142857]">
167
- {line || ' '}
168
- </td>
169
- </tr>
170
- )
171
- })}
172
- </tbody>
173
- </table>
268
+ </tr>
269
+ )
270
+ })}
271
+ </tbody>
272
+ </table>
273
+ </div>
274
+ {hasOverflow && scrolledFromStart && (
275
+ <div
276
+ className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none"
277
+ style={{ background: fadeGradientLeft }}
278
+ aria-hidden="true"
279
+ />
280
+ )}
281
+ {hasOverflow && !scrolledToEnd && (
282
+ <div
283
+ className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none"
284
+ style={{ background: fadeGradient }}
285
+ aria-hidden="true"
286
+ />
287
+ )}
174
288
  </div>
175
289
  )
176
290
  }
@@ -13,3 +13,7 @@ export { H1, H2, H3, H4 } from './heading'
13
13
  export { ParamTable, Schema } from './param-table'
14
14
  export { Changelog, ChangelogEntry, Change } from './changelog'
15
15
  export { MethodBadge, StatusBadge, Endpoint } from './api-badge'
16
+ export { Tooltip } from './tooltip'
17
+ export { Frame } from './frame'
18
+ export { DarkImage } from './dark-image'
19
+ export { LinkPreview } from './link-preview'
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useCallback, useEffect } from 'react'
4
+ import Link from 'next/link'
5
+
6
+ interface LinkPreviewProps {
7
+ href: string
8
+ children: React.ReactNode
9
+ }
10
+
11
+ export function LinkPreview({ href, children }: LinkPreviewProps) {
12
+ const [showPreview, setShowPreview] = useState(false)
13
+ const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
14
+ const linkRef = useRef<HTMLAnchorElement>(null)
15
+ const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
16
+ const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
17
+
18
+ const isInternal = href.startsWith('/docs/') || href.startsWith('/docs')
19
+
20
+ // Derive a human-readable title from the link text (children) or href
21
+ const title = typeof children === 'string'
22
+ ? children
23
+ : href
24
+ .replace(/^\/docs\/?/, '')
25
+ .replace(/\//g, ' > ')
26
+ .replace(/-/g, ' ')
27
+ .replace(/\b\w/g, (c) => c.toUpperCase()) || 'Documentation'
28
+
29
+ const updatePosition = useCallback(() => {
30
+ if (!linkRef.current) return
31
+ const rect = linkRef.current.getBoundingClientRect()
32
+ setPosition({
33
+ top: rect.top,
34
+ left: rect.left + rect.width / 2,
35
+ })
36
+ }, [])
37
+
38
+ const handleMouseEnter = useCallback(() => {
39
+ if (!isInternal) return
40
+ if (leaveTimerRef.current) {
41
+ clearTimeout(leaveTimerRef.current)
42
+ leaveTimerRef.current = null
43
+ }
44
+ hoverTimerRef.current = setTimeout(() => {
45
+ updatePosition()
46
+ setShowPreview(true)
47
+ }, 300)
48
+ }, [isInternal, updatePosition])
49
+
50
+ const handleMouseLeave = useCallback(() => {
51
+ if (hoverTimerRef.current) {
52
+ clearTimeout(hoverTimerRef.current)
53
+ hoverTimerRef.current = null
54
+ }
55
+ leaveTimerRef.current = setTimeout(() => {
56
+ setShowPreview(false)
57
+ }, 150)
58
+ }, [])
59
+
60
+ // Cleanup timers on unmount
61
+ useEffect(() => {
62
+ return () => {
63
+ if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current)
64
+ if (leaveTimerRef.current) clearTimeout(leaveTimerRef.current)
65
+ }
66
+ }, [])
67
+
68
+ // For external links, render a plain anchor
69
+ if (!isInternal) {
70
+ return (
71
+ <a href={href} target="_blank" rel="noopener noreferrer">
72
+ {children}
73
+ </a>
74
+ )
75
+ }
76
+
77
+ return (
78
+ <>
79
+ <Link
80
+ ref={linkRef}
81
+ href={href}
82
+ onMouseEnter={handleMouseEnter}
83
+ onMouseLeave={handleMouseLeave}
84
+ onFocus={handleMouseEnter}
85
+ onBlur={handleMouseLeave}
86
+ >
87
+ {children}
88
+ </Link>
89
+ {showPreview && position && (
90
+ <span
91
+ className="link-preview-card"
92
+ style={{
93
+ position: 'fixed',
94
+ top: position.top - 8,
95
+ left: position.left,
96
+ transform: 'translate(-50%, -100%)',
97
+ zIndex: 100,
98
+ }}
99
+ onMouseEnter={() => {
100
+ if (leaveTimerRef.current) {
101
+ clearTimeout(leaveTimerRef.current)
102
+ leaveTimerRef.current = null
103
+ }
104
+ }}
105
+ onMouseLeave={handleMouseLeave}
106
+ >
107
+ <span className="block px-3 py-2 rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)] shadow-lg max-w-[240px]">
108
+ <span className="block text-sm font-medium text-[var(--color-text)] truncate">
109
+ {title}
110
+ </span>
111
+ <span className="block text-xs text-[var(--color-text-tertiary)] mt-0.5">
112
+ Click to navigate
113
+ </span>
114
+ </span>
115
+ </span>
116
+ )}
117
+ </>
118
+ )
119
+ }
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useCallback, type ReactNode } from 'react'
4
+
5
+ interface TooltipProps {
6
+ children: ReactNode
7
+ content: string
8
+ side?: 'top' | 'bottom' | 'left' | 'right'
9
+ }
10
+
11
+ const arrowStyles: Record<string, React.CSSProperties> = {
12
+ top: {
13
+ bottom: -4,
14
+ left: '50%',
15
+ transform: 'translateX(-50%) rotate(45deg)',
16
+ },
17
+ bottom: {
18
+ top: -4,
19
+ left: '50%',
20
+ transform: 'translateX(-50%) rotate(45deg)',
21
+ },
22
+ left: {
23
+ right: -4,
24
+ top: '50%',
25
+ transform: 'translateY(-50%) rotate(45deg)',
26
+ },
27
+ right: {
28
+ left: -4,
29
+ top: '50%',
30
+ transform: 'translateY(-50%) rotate(45deg)',
31
+ },
32
+ }
33
+
34
+ const positionStyles: Record<string, React.CSSProperties> = {
35
+ top: {
36
+ bottom: '100%',
37
+ left: '50%',
38
+ transform: 'translateX(-50%)',
39
+ marginBottom: 8,
40
+ },
41
+ bottom: {
42
+ top: '100%',
43
+ left: '50%',
44
+ transform: 'translateX(-50%)',
45
+ marginTop: 8,
46
+ },
47
+ left: {
48
+ right: '100%',
49
+ top: '50%',
50
+ transform: 'translateY(-50%)',
51
+ marginRight: 8,
52
+ },
53
+ right: {
54
+ left: '100%',
55
+ top: '50%',
56
+ transform: 'translateY(-50%)',
57
+ marginLeft: 8,
58
+ },
59
+ }
60
+
61
+ export function Tooltip({ children, content, side = 'top' }: TooltipProps) {
62
+ const [visible, setVisible] = useState(false)
63
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
64
+
65
+ const show = useCallback(() => {
66
+ timeoutRef.current = setTimeout(() => setVisible(true), 150)
67
+ }, [])
68
+
69
+ const hide = useCallback(() => {
70
+ if (timeoutRef.current) {
71
+ clearTimeout(timeoutRef.current)
72
+ timeoutRef.current = null
73
+ }
74
+ setVisible(false)
75
+ }, [])
76
+
77
+ return (
78
+ <span
79
+ className="relative inline-flex"
80
+ onMouseEnter={show}
81
+ onMouseLeave={hide}
82
+ onFocus={show}
83
+ onBlur={hide}
84
+ >
85
+ {children}
86
+ {visible && (
87
+ <span
88
+ role="tooltip"
89
+ className="tooltip-content absolute z-50 whitespace-nowrap bg-[var(--color-gray-900)] text-white text-xs px-2.5 py-1.5 rounded-lg shadow-lg tooltip-fade-in pointer-events-none"
90
+ style={positionStyles[side]}
91
+ >
92
+ {content}
93
+ <span
94
+ className="tooltip-content absolute h-2 w-2 bg-[var(--color-gray-900)]"
95
+ style={arrowStyles[side]}
96
+ />
97
+ </span>
98
+ )}
99
+ </span>
100
+ )
101
+ }
@@ -1,49 +1,196 @@
1
1
  'use client'
2
2
 
3
- import { useContext } from 'react'
3
+ import { useContext, useState, useRef, useEffect, useCallback, KeyboardEvent } from 'react'
4
4
  import { SyntaxThemeContext } from '@/contexts/syntax-theme'
5
5
  import { AVAILABLE_THEMES, DEFAULT_THEME } from '@/lib/highlight'
6
6
  import { Palette } from 'lucide-react'
7
7
 
8
8
  export function SyntaxThemeSelector() {
9
9
  const context = useContext(SyntaxThemeContext)
10
+ const [isOpen, setIsOpen] = useState(false)
11
+ const [activeIndex, setActiveIndex] = useState(-1)
12
+ const triggerRef = useRef<HTMLButtonElement>(null)
13
+ const listRef = useRef<HTMLDivElement>(null)
14
+ const optionRefs = useRef<(HTMLButtonElement | null)[]>([])
15
+ const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
10
16
 
11
- // Safely handle when context isn't available (during SSG)
12
17
  const theme = context?.theme ?? DEFAULT_THEME
13
18
  const setTheme = context?.setTheme ?? (() => {})
14
19
  const availableThemes = context?.availableThemes ?? AVAILABLE_THEMES
15
20
 
21
+ // Find the index of the current active theme
22
+ const activeThemeIndex = availableThemes.findIndex((t) => t.name === theme)
23
+
24
+ const open = useCallback(() => {
25
+ if (closeTimeoutRef.current) {
26
+ clearTimeout(closeTimeoutRef.current)
27
+ closeTimeoutRef.current = null
28
+ }
29
+ setIsOpen(true)
30
+ setActiveIndex(activeThemeIndex >= 0 ? activeThemeIndex : 0)
31
+ }, [activeThemeIndex])
32
+
33
+ const close = useCallback(() => {
34
+ setIsOpen(false)
35
+ setActiveIndex(-1)
36
+ }, [])
37
+
38
+ const delayedClose = useCallback(() => {
39
+ closeTimeoutRef.current = setTimeout(close, 150)
40
+ }, [close])
41
+
42
+ const cancelClose = useCallback(() => {
43
+ if (closeTimeoutRef.current) {
44
+ clearTimeout(closeTimeoutRef.current)
45
+ closeTimeoutRef.current = null
46
+ }
47
+ }, [])
48
+
49
+ // Focus the active option when activeIndex changes while open
50
+ useEffect(() => {
51
+ if (isOpen && activeIndex >= 0) {
52
+ optionRefs.current[activeIndex]?.focus()
53
+ }
54
+ }, [isOpen, activeIndex])
55
+
56
+ // Close on outside click
57
+ useEffect(() => {
58
+ if (!isOpen) return
59
+ function handleClick(e: MouseEvent) {
60
+ const target = e.target as Node
61
+ if (
62
+ !triggerRef.current?.contains(target) &&
63
+ !listRef.current?.contains(target)
64
+ ) {
65
+ close()
66
+ }
67
+ }
68
+ document.addEventListener('mousedown', handleClick)
69
+ return () => document.removeEventListener('mousedown', handleClick)
70
+ }, [isOpen, close])
71
+
72
+ // Close on Escape
73
+ useEffect(() => {
74
+ if (!isOpen) return
75
+ function handleEsc(e: globalThis.KeyboardEvent) {
76
+ if (e.key === 'Escape') {
77
+ close()
78
+ triggerRef.current?.focus()
79
+ }
80
+ }
81
+ document.addEventListener('keydown', handleEsc)
82
+ return () => document.removeEventListener('keydown', handleEsc)
83
+ }, [isOpen, close])
84
+
85
+ function selectTheme(index: number) {
86
+ const t = availableThemes[index]
87
+ if (t) {
88
+ setTheme(t.name)
89
+ close()
90
+ triggerRef.current?.focus()
91
+ }
92
+ }
93
+
94
+ function handleTriggerKeyDown(e: KeyboardEvent) {
95
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
96
+ e.preventDefault()
97
+ open()
98
+ } else if (e.key === 'ArrowUp') {
99
+ e.preventDefault()
100
+ open()
101
+ setActiveIndex(availableThemes.length - 1)
102
+ }
103
+ }
104
+
105
+ function handleOptionKeyDown(e: KeyboardEvent, index: number) {
106
+ switch (e.key) {
107
+ case 'ArrowDown':
108
+ e.preventDefault()
109
+ setActiveIndex((index + 1) % availableThemes.length)
110
+ break
111
+ case 'ArrowUp':
112
+ e.preventDefault()
113
+ setActiveIndex((index - 1 + availableThemes.length) % availableThemes.length)
114
+ break
115
+ case 'Home':
116
+ e.preventDefault()
117
+ setActiveIndex(0)
118
+ break
119
+ case 'End':
120
+ e.preventDefault()
121
+ setActiveIndex(availableThemes.length - 1)
122
+ break
123
+ case 'Enter':
124
+ case ' ':
125
+ e.preventDefault()
126
+ selectTheme(index)
127
+ break
128
+ case 'Tab':
129
+ close()
130
+ break
131
+ }
132
+ }
133
+
134
+ const listboxId = 'syntax-theme-listbox'
135
+
16
136
  return (
17
- <div className="relative group">
137
+ <div
138
+ className="relative"
139
+ onMouseEnter={() => { open(); cancelClose() }}
140
+ onMouseLeave={delayedClose}
141
+ >
18
142
  <button
143
+ ref={triggerRef}
19
144
  className="flex items-center gap-2 px-3 py-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text)] rounded-md hover:bg-[var(--color-bg-secondary)] transition-colors"
20
145
  title="Syntax theme"
21
146
  aria-label={`Syntax theme: ${theme}`}
22
147
  aria-haspopup="listbox"
148
+ aria-expanded={isOpen}
149
+ aria-controls={isOpen ? listboxId : undefined}
150
+ onClick={() => { isOpen ? close() : open() }}
151
+ onKeyDown={handleTriggerKeyDown}
23
152
  >
24
153
  <Palette size={16} />
25
154
  <span className="hidden sm:inline">Theme</span>
26
155
  </button>
27
156
 
28
- <div className="absolute right-0 top-full mt-1 w-48 py-1 bg-[var(--color-bg)] border border-[var(--color-border)] rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-50">
29
- <div className="px-3 py-1.5 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide">
157
+ <div
158
+ ref={listRef}
159
+ id={listboxId}
160
+ role="listbox"
161
+ aria-label="Syntax theme"
162
+ aria-activedescendant={activeIndex >= 0 ? `syntax-theme-option-${activeIndex}` : undefined}
163
+ className={`absolute right-0 top-full mt-1 w-48 py-1 bg-[var(--color-bg)] border border-[var(--color-border)] rounded-lg shadow-lg transition-all z-50 ${
164
+ isOpen ? 'opacity-100 visible' : 'opacity-0 invisible pointer-events-none'
165
+ }`}
166
+ >
167
+ <div className="px-3 py-1.5 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wide" aria-hidden="true">
30
168
  Syntax Theme
31
169
  </div>
32
- {availableThemes.map((t) => (
33
- <button
34
- key={t.name}
35
- onClick={() => setTheme(t.name)}
36
- className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 hover:bg-[var(--color-bg-secondary)] transition-colors ${
37
- theme === t.name ? 'text-[var(--color-primary)]' : 'text-[var(--color-text)]'
38
- }`}
39
- >
40
- <span
41
- className={`w-3 h-3 rounded-full ${t.isDark ? 'bg-gray-800' : 'bg-gray-200'}`}
42
- />
43
- {t.label}
44
- {theme === t.name && <span className="ml-auto text-xs">Active</span>}
45
- </button>
46
- ))}
170
+ {availableThemes.map((t, index) => {
171
+ const isSelected = theme === t.name
172
+ return (
173
+ <button
174
+ key={t.name}
175
+ ref={(el) => { optionRefs.current[index] = el }}
176
+ id={`syntax-theme-option-${index}`}
177
+ role="option"
178
+ aria-selected={isSelected}
179
+ tabIndex={-1}
180
+ onClick={() => selectTheme(index)}
181
+ onKeyDown={(e) => handleOptionKeyDown(e, index)}
182
+ className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 hover:bg-[var(--color-bg-secondary)] transition-colors ${
183
+ isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--color-text)]'
184
+ } ${activeIndex === index ? 'bg-[var(--color-bg-secondary)]' : ''}`}
185
+ >
186
+ <span
187
+ className={`w-3 h-3 rounded-full ${t.isDark ? 'bg-gray-800' : 'bg-gray-200'}`}
188
+ />
189
+ {t.label}
190
+ {isSelected && <span className="ml-auto text-xs">Active</span>}
191
+ </button>
192
+ )
193
+ })}
47
194
  </div>
48
195
  </div>
49
196
  )
@@ -121,6 +121,12 @@
121
121
  :root:not(.light) .feedback-negative { background-color: rgba(127, 29, 29, 0.3); color: #f87171; }
122
122
  }
123
123
 
124
+ /* Tooltip — dark mode overrides */
125
+ :root.dark .tooltip-content { background-color: var(--color-gray-100); color: var(--color-gray-900); }
126
+ @media (prefers-color-scheme: dark) {
127
+ :root:not(.light) .tooltip-content { background-color: var(--color-gray-100); color: var(--color-gray-900); }
128
+ }
129
+
124
130
  /* ========================
125
131
  Base — in @layer base so Tailwind utilities can override
126
132
  ======================== */
@@ -463,3 +469,36 @@ input:focus-visible, textarea:focus-visible {
463
469
  animation: shimmer 1.5s infinite;
464
470
  border-radius: 0.25rem;
465
471
  }
472
+
473
+ /* ========================
474
+ Tooltip fade-in
475
+ ======================== */
476
+ @keyframes tooltip-fade-in {
477
+ from { opacity: 0; }
478
+ to { opacity: 1; }
479
+ }
480
+ .tooltip-fade-in {
481
+ animation: tooltip-fade-in 150ms ease-out;
482
+ }
483
+
484
+ /* ========================
485
+ Page transitions — subtle fade-in for article content
486
+ ======================== */
487
+ @keyframes page-fade-in {
488
+ from { opacity: 0; transform: translateY(4px); }
489
+ to { opacity: 1; transform: translateY(0); }
490
+ }
491
+ .prose {
492
+ animation: page-fade-in 200ms ease-out;
493
+ }
494
+
495
+ /* ========================
496
+ Link preview card
497
+ ======================== */
498
+ @keyframes link-preview-in {
499
+ from { opacity: 0; transform: translate(-50%, -100%) translateY(4px); }
500
+ to { opacity: 1; transform: translate(-50%, -100%) translateY(0); }
501
+ }
502
+ .link-preview-card {
503
+ animation: link-preview-in 150ms ease-out;
504
+ }
@@ -31,7 +31,7 @@ export function validateSlug(input) {
31
31
  }
32
32
  export function sanitizeForShell(input) {
33
33
  // Only allow safe characters for git refs, filenames, etc.
34
- if (!/^[a-zA-Z0-9\/_~.^@{}\-]+$/.test(input)) {
34
+ if (!/^[a-zA-Z0-9/_~.^@{}-]+$/.test(input)) {
35
35
  throw new Error(`Unsafe characters in input: ${input}`);
36
36
  }
37
37
  return input;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skrypt-ai",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "AI-powered documentation generator with code examples",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",