doccupine 0.0.105 → 0.0.107

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 (28) hide show
  1. package/dist/templates/components/Chat.d.ts +1 -1
  2. package/dist/templates/components/Chat.js +52 -40
  3. package/dist/templates/components/SearchModalContent.d.ts +1 -1
  4. package/dist/templates/components/SearchModalContent.js +27 -20
  5. package/dist/templates/components/layout/Accordion.d.ts +1 -1
  6. package/dist/templates/components/layout/Accordion.js +14 -4
  7. package/dist/templates/components/layout/Button.d.ts +1 -1
  8. package/dist/templates/components/layout/Button.js +5 -1
  9. package/dist/templates/components/layout/Code.d.ts +1 -1
  10. package/dist/templates/components/layout/Code.js +14 -4
  11. package/dist/templates/components/layout/DocsComponents.d.ts +1 -1
  12. package/dist/templates/components/layout/DocsComponents.js +12 -6
  13. package/dist/templates/components/layout/Footer.d.ts +1 -1
  14. package/dist/templates/components/layout/Footer.js +2 -3
  15. package/dist/templates/components/layout/GlobalStyles.d.ts +1 -1
  16. package/dist/templates/components/layout/GlobalStyles.js +14 -0
  17. package/dist/templates/components/layout/Header.d.ts +1 -1
  18. package/dist/templates/components/layout/Header.js +0 -2
  19. package/dist/templates/components/layout/SectionBar.d.ts +1 -1
  20. package/dist/templates/components/layout/SectionBar.js +16 -4
  21. package/dist/templates/components/layout/SharedStyles.d.ts +1 -1
  22. package/dist/templates/components/layout/SharedStyles.js +4 -17
  23. package/dist/templates/mdx/platform/navigation-settings.mdx.d.ts +1 -1
  24. package/dist/templates/mdx/platform/navigation-settings.mdx.js +47 -8
  25. package/dist/templates/mdx/update.mdx.d.ts +1 -1
  26. package/dist/templates/mdx/update.mdx.js +2 -2
  27. package/dist/templates/package.js +1 -1
  28. package/package.json +1 -1
@@ -1 +1 @@
1
- export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Button } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n thinScrollbar,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 12px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 40%, transparent)`};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 60%, transparent)`};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) => theme.colors.surface};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 10%, transparent)`};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { setIsOpen, isOpen, answer, setAnswer, chatInputRef } =\n useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={() => {\n const next = !isOpen;\n setIsOpen(next);\n if (next) {\n if (answer.length === 0) {\n setAnswer([\n { text: \"Hey there, how can I assist you?\", answer: true },\n ]);\n }\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }\n }}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <StyledChatCloseButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw size={18} />\n </StyledChatCloseButton>\n <StyledChatCloseButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </StyledChatCloseButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink\n key={src.id}\n href={href}\n onClick={() => {\n if (window.innerWidth <= 992) {\n closeChat();\n }\n }}\n >\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: \"Hey there, how can I assist you?\", answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
1
+ export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Button, IconButton } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n thinScrollbar,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 12px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 40%, transparent)`};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 60%, transparent)`};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) => theme.colors.surface};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 10%, transparent)`};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { toggleChat, isOpen } = useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={toggleChat}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <IconButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw />\n </IconButton>\n <IconButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </IconButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink\n key={src.id}\n href={href}\n onClick={() => {\n if (window.innerWidth <= 992) {\n closeChat();\n }\n }}\n >\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n toggleChat: () => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n toggleChat: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n const isOpenRef = useRef(isOpen);\n\n useEffect(() => {\n isOpenRef.current = isOpen;\n }, [isOpen]);\n\n // Open the assistant, seeding a greeting the first time and focusing the\n // input once the panel has slid in (matches the 0.3s panel transition).\n const openChat = useCallback(() => {\n setIsOpen(true);\n setAnswer((prev) =>\n prev.length === 0\n ? [{ text: \"Hey there, how can I assist you?\", answer: true }]\n : prev,\n );\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }, []);\n\n const toggleChat = useCallback(() => {\n if (isOpenRef.current) {\n setIsOpen(false);\n } else {\n openChat();\n }\n }, [openChat]);\n\n // Global Cmd/Ctrl+I toggles the assistant, but only when chat is enabled.\n useEffect(() => {\n if (!isChatActive) return;\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"i\") {\n e.preventDefault();\n toggleChat();\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [isChatActive, toggleChat]);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: \"Hey there, how can I assist you?\", answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n toggleChat,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
@@ -2,13 +2,14 @@ import { CHAT_WIDTH } from "../app/theme.js";
2
2
  export const chatTemplate = `"use client";
3
3
  import React, {
4
4
  createContext,
5
+ useCallback,
5
6
  useContext,
6
7
  useEffect,
7
8
  useRef,
8
9
  useState,
9
10
  } from "react";
10
11
  import styled, { css, keyframes } from "styled-components";
11
- import { Button } from "cherry-styled-components";
12
+ import { Button, IconButton } from "cherry-styled-components";
12
13
  import { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from "lucide-react";
13
14
  import remarkGfm from "remark-gfm";
14
15
  import rehypeHighlight from "rehype-highlight";
@@ -669,24 +670,6 @@ const StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>\`
669
670
  color: \${({ theme }) => theme.colors.dark};
670
671
  \`;
671
672
 
672
- const StyledChatCloseButton = styled.button<{ theme: Theme }>\`
673
- background: transparent;
674
- border: none;
675
- cursor: pointer;
676
- padding: 0;
677
- margin: 0;
678
- color: \${({ theme }) => theme.colors.primary};
679
-
680
- &:hover {
681
- color: \${({ theme }) => theme.colors.primaryDark};
682
- transform: scale(1.05);
683
- }
684
-
685
- &:active {
686
- transform: scale(0.95);
687
- }
688
- \`;
689
-
690
673
  type Source = {
691
674
  id: string;
692
675
  path: string;
@@ -787,25 +770,11 @@ function RainbowInput({
787
770
  }
788
771
 
789
772
  function ChatButtonCTA() {
790
- const { setIsOpen, isOpen, answer, setAnswer, chatInputRef } =
791
- useContext(ChatContext);
773
+ const { toggleChat, isOpen } = useContext(ChatContext);
792
774
 
793
775
  return (
794
776
  <StyledGlowSmallButton
795
- onClick={() => {
796
- const next = !isOpen;
797
- setIsOpen(next);
798
- if (next) {
799
- if (answer.length === 0) {
800
- setAnswer([
801
- { text: "Hey there, how can I assist you?", answer: true },
802
- ]);
803
- }
804
- setTimeout(() => {
805
- chatInputRef.current?.focus();
806
- }, 350);
807
- }
808
- }}
777
+ onClick={toggleChat}
809
778
  aria-label="Ask AI Assistant"
810
779
  $hasContent={isOpen}
811
780
  type="button"
@@ -854,20 +823,20 @@ function Chat() {
854
823
  <h3>AI Assistant</h3>
855
824
  </StyledChatTitleIconWrapper>
856
825
  <StyledChatTitleIconWrapper>
857
- <StyledChatCloseButton
826
+ <IconButton
858
827
  onClick={resetChat}
859
828
  aria-label="Reset chat history"
860
829
  title="Reset chat history"
861
830
  >
862
- <RotateCcw size={18} />
863
- </StyledChatCloseButton>
864
- <StyledChatCloseButton
831
+ <RotateCcw />
832
+ </IconButton>
833
+ <IconButton
865
834
  onClick={closeChat}
866
835
  aria-label="Close chat"
867
836
  title="Close chat"
868
837
  >
869
838
  <X />
870
- </StyledChatCloseButton>
839
+ </IconButton>
871
840
  </StyledChatTitleIconWrapper>
872
841
  </StyledChatTitle>
873
842
  {answer &&
@@ -953,6 +922,7 @@ function Chat() {
953
922
  const ChatContext = createContext<{
954
923
  isOpen: boolean;
955
924
  setIsOpen: (isOpen: boolean) => void;
925
+ toggleChat: () => void;
956
926
  isChatActive: boolean;
957
927
  question: string;
958
928
  setQuestion: (q: string) => void;
@@ -967,6 +937,7 @@ const ChatContext = createContext<{
967
937
  }>({
968
938
  isOpen: false,
969
939
  setIsOpen: () => {},
940
+ toggleChat: () => {},
970
941
  isChatActive: false,
971
942
  question: "",
972
943
  setQuestion: () => {},
@@ -993,6 +964,46 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
993
964
  const [answer, setAnswer] = useState<Answer[]>([]);
994
965
  const abortRef = useRef<AbortController | null>(null);
995
966
  const chatInputRef = useRef<HTMLInputElement | null>(null);
967
+ const isOpenRef = useRef(isOpen);
968
+
969
+ useEffect(() => {
970
+ isOpenRef.current = isOpen;
971
+ }, [isOpen]);
972
+
973
+ // Open the assistant, seeding a greeting the first time and focusing the
974
+ // input once the panel has slid in (matches the 0.3s panel transition).
975
+ const openChat = useCallback(() => {
976
+ setIsOpen(true);
977
+ setAnswer((prev) =>
978
+ prev.length === 0
979
+ ? [{ text: "Hey there, how can I assist you?", answer: true }]
980
+ : prev,
981
+ );
982
+ setTimeout(() => {
983
+ chatInputRef.current?.focus();
984
+ }, 350);
985
+ }, []);
986
+
987
+ const toggleChat = useCallback(() => {
988
+ if (isOpenRef.current) {
989
+ setIsOpen(false);
990
+ } else {
991
+ openChat();
992
+ }
993
+ }, [openChat]);
994
+
995
+ // Global Cmd/Ctrl+I toggles the assistant, but only when chat is enabled.
996
+ useEffect(() => {
997
+ if (!isChatActive) return;
998
+ function handleKeyDown(e: KeyboardEvent) {
999
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "i") {
1000
+ e.preventDefault();
1001
+ toggleChat();
1002
+ }
1003
+ }
1004
+ document.addEventListener("keydown", handleKeyDown);
1005
+ return () => document.removeEventListener("keydown", handleKeyDown);
1006
+ }, [isChatActive, toggleChat]);
996
1007
 
997
1008
  async function ask(e: React.FormEvent) {
998
1009
  e.preventDefault();
@@ -1145,6 +1156,7 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
1145
1156
  value={{
1146
1157
  isOpen,
1147
1158
  setIsOpen,
1159
+ toggleChat,
1148
1160
  isChatActive,
1149
1161
  question,
1150
1162
  setQuestion,
@@ -1 +1 @@
1
- export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Search } from \"lucide-react\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\n\nexport interface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\nexport interface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\nexport interface SearchModalContentProps {\n isClosing: boolean;\n closeSearch: () => void;\n onCloseAnimationEnd: () => void;\n query: string;\n setQuery: (q: string) => void;\n activeIndex: number;\n setActiveIndex: (i: number | ((prev: number) => number)) => void;\n resultsRef: React.RefObject<HTMLUListElement | null>;\n onKeyDown: (e: React.KeyboardEvent) => void;\n merged: MergedResult[];\n sectionLabels: Record<string, string>;\n isSearching: boolean;\n navigate: (slug: string) => void;\n}\n\nconst ANIMATION_MS = 150;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n /* Backdrop is intentionally near-black in both modes \u2014 covers everything\n behind the modal regardless of the user's theme. */\n background: rgba(0, 0, 0, 0.5);\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xs};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding-bottom: 8px;\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n margin: 8px 0 0 0;\n padding: 0 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n ${thinScrollbar};\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n transition: background 0.15s ease;\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n background: color-mix(\n in srgb,\n ${theme.colors.primaryLight} 20%,\n transparent\n );\n `}\n\n &:hover {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 15%, transparent)`};\n }\n`;\n\nconst StyledResultTitle = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n`;\n\nconst StyledResultMeta = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n display: block;\n margin-top: 2px;\n`;\n\nconst StyledSnippet = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.grayDark};\n display: block;\n margin-top: 4px;\n line-height: 1.4;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n & mark {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 35%, transparent)`};\n color: inherit;\n border-radius: 4px;\n padding: 0 1px;\n }\n`;\n\nconst StyledEmpty = styled.div<{ theme: Theme }>`\n padding: 20px 20px 12px;\n min-height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n`;\n\nconst StyledKbd = styled.kbd<{ theme: Theme }>`\n font-size: 11px;\n font-family: inherit;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n padding: 2px 6px;\n border-radius: 4px;\n margin-left: auto;\n font-weight: 600;\n display: none;\n\n ${mq(\"lg\")} {\n display: initial;\n }\n`;\n\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\nfunction highlightMatch(snippet: string, query: string): string {\n const escaped = escapeHtml(snippet);\n if (!query.trim()) return escaped;\n const q = escapeHtml(query.trim());\n const regex = new RegExp(\n `(${q.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")})`,\n \"gi\",\n );\n return escaped.replace(regex, \"<mark>$1</mark>\");\n}\n\nexport function SearchModalContent({\n isClosing,\n closeSearch,\n onCloseAnimationEnd,\n query,\n setQuery,\n activeIndex,\n setActiveIndex,\n resultsRef,\n onKeyDown,\n merged,\n sectionLabels,\n isSearching,\n navigate,\n}: SearchModalContentProps) {\n return (\n <StyledBackdrop\n $isClosing={isClosing}\n onClick={closeSearch}\n onAnimationEnd={onCloseAnimationEnd}\n >\n <StyledModal $isClosing={isClosing} onClick={(e) => e.stopPropagation()}>\n <StyledInputWrapper>\n <Search size={18} />\n <StyledInput\n autoFocus\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={onKeyDown}\n placeholder=\"Search docs...\"\n autoComplete=\"off\"\n spellCheck={false}\n />\n <StyledKbd>Esc</StyledKbd>\n </StyledInputWrapper>\n {merged.length > 0 ? (\n <StyledResults ref={resultsRef}>\n {merged.map((result, index) => (\n <StyledResultItem\n key={result.page.slug + result.page.section}\n $isActive={index === activeIndex}\n onClick={() => navigate(result.page.slug)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n <StyledResultTitle>{result.page.title}</StyledResultTitle>\n <StyledResultMeta>\n {result.page.section\n ? `${sectionLabels[result.page.section] || result.page.section} / `\n : \"\"}\n {result.page.category}\n </StyledResultMeta>\n {result.snippet && (\n <StyledSnippet\n dangerouslySetInnerHTML={{\n __html: highlightMatch(result.snippet, query),\n }}\n />\n )}\n </StyledResultItem>\n ))}\n </StyledResults>\n ) : (\n <StyledEmpty>\n {isSearching ? <Spinner size={18} /> : \"No results found\"}\n </StyledEmpty>\n )}\n </StyledModal>\n </StyledBackdrop>\n );\n}\n";
1
+ export declare const searchModalContentTemplate = "\"use client\";\nimport React from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Search, X } from \"lucide-react\";\nimport { IconButton } from \"cherry-styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { Spinner } from \"@/components/Spinner\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\n\nexport interface PageItem {\n slug: string;\n title: string;\n description?: string;\n category: string;\n section?: string;\n}\n\nexport interface MergedResult {\n page: PageItem;\n snippet?: string;\n}\n\nexport interface SearchModalContentProps {\n isClosing: boolean;\n closeSearch: () => void;\n onCloseAnimationEnd: () => void;\n query: string;\n setQuery: (q: string) => void;\n activeIndex: number;\n setActiveIndex: (i: number | ((prev: number) => number)) => void;\n resultsRef: React.RefObject<HTMLUListElement | null>;\n onKeyDown: (e: React.KeyboardEvent) => void;\n merged: MergedResult[];\n sectionLabels: Record<string, string>;\n isSearching: boolean;\n navigate: (slug: string) => void;\n}\n\nconst ANIMATION_MS = 300;\n\nconst backdropIn = keyframes`\n from { opacity: 0; }\n to { opacity: 1; }\n`;\n\nconst backdropOut = keyframes`\n from { opacity: 1; }\n to { opacity: 0; }\n`;\n\nconst modalIn = keyframes`\n from { opacity: 0; transform: scale(0.96) translateY(-8px); }\n to { opacity: 1; transform: scale(1) translateY(0); }\n`;\n\nconst modalOut = keyframes`\n from { opacity: 1; transform: scale(1) translateY(0); }\n to { opacity: 0; transform: scale(0.96) translateY(-8px); }\n`;\n\nconst StyledBackdrop = styled.div<{ theme: Theme; $isClosing: boolean }>`\n position: fixed;\n inset: 0;\n z-index: 9999;\n /* Backdrop is intentionally near-black in both modes \u2014 covers everything\n behind the modal regardless of the user's theme. */\n background: rgba(0, 0, 0, 0.5);\n backdrop-filter: blur(4px);\n -webkit-backdrop-filter: blur(4px);\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding: 20px;\n animation: ${({ $isClosing }) => ($isClosing ? backdropOut : backdropIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n padding: 120px 20px 20px 20px;\n }\n`;\n\nconst StyledModal = styled.div<{ theme: Theme; $isClosing: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.xs};\n width: 100%;\n max-width: 560px;\n max-height: calc(100dvh - 40px);\n display: flex;\n flex-direction: column;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding-bottom: 8px;\n animation: ${({ $isClosing }) => ($isClosing ? modalOut : modalIn)}\n ${ANIMATION_MS}ms ease forwards;\n\n ${mq(\"lg\")} {\n max-height: calc(100dvh - 240px);\n }\n`;\n\nconst StyledInputWrapper = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 16px;\n flex-shrink: 0;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg.lucide {\n color: ${({ theme }) => theme.colors.gray};\n flex-shrink: 0;\n }\n`;\n\nconst StyledInput = styled.input<{ theme: Theme }>`\n flex: 1;\n border: none;\n outline: none;\n background: transparent;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n color: ${({ theme }) => theme.colors.dark};\n font-family: inherit;\n\n &::placeholder {\n color: ${({ theme }) => theme.colors.gray};\n }\n`;\n\nconst StyledResults = styled.ul<{ theme: Theme }>`\n list-style: none;\n margin: 8px 0 0 0;\n padding: 0 8px;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n -webkit-overflow-scrolling: touch;\n ${thinScrollbar};\n\n /* The results list is a keyboard-focusable scroll container; frame it with\n the app focus ring (inset so it hugs the visible viewport and isn't clipped\n by the scroll overflow) instead of the browser's default outline. */\n &:focus-visible {\n outline: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow: inset 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nconst StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>`\n padding: 10px 12px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n transition: background 0.15s ease;\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n background: color-mix(\n in srgb,\n ${theme.colors.primaryLight} 20%,\n transparent\n );\n `}\n\n &:hover {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 15%, transparent)`};\n }\n`;\n\nconst StyledResultTitle = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n`;\n\nconst StyledResultMeta = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n display: block;\n margin-top: 2px;\n`;\n\nconst StyledSnippet = styled.span<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.grayDark};\n display: block;\n margin-top: 4px;\n line-height: 1.4;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n\n & mark {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 35%, transparent)`};\n color: inherit;\n border-radius: 4px;\n padding: 0 1px;\n }\n`;\n\nconst StyledEmpty = styled.div<{ theme: Theme }>`\n padding: 20px 20px 12px;\n min-height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.gray};\n`;\n\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\nfunction highlightMatch(snippet: string, query: string): string {\n const escaped = escapeHtml(snippet);\n if (!query.trim()) return escaped;\n const q = escapeHtml(query.trim());\n const regex = new RegExp(\n `(${q.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")})`,\n \"gi\",\n );\n return escaped.replace(regex, \"<mark>$1</mark>\");\n}\n\nexport function SearchModalContent({\n isClosing,\n closeSearch,\n onCloseAnimationEnd,\n query,\n setQuery,\n activeIndex,\n setActiveIndex,\n resultsRef,\n onKeyDown,\n merged,\n sectionLabels,\n isSearching,\n navigate,\n}: SearchModalContentProps) {\n return (\n <StyledBackdrop\n $isClosing={isClosing}\n onClick={closeSearch}\n onAnimationEnd={(e) => {\n // animationend bubbles, so this handler also fires for animations\n // completing on descendants (the inner modal, icons, result items).\n // Only the backdrop's own exit animation should trigger the unmount -\n // otherwise a child finishing its animation first tears the modal down\n // before the close animation plays, making it vanish instantly.\n if (e.target === e.currentTarget) onCloseAnimationEnd();\n }}\n >\n <StyledModal $isClosing={isClosing} onClick={(e) => e.stopPropagation()}>\n <StyledInputWrapper>\n <Search size={18} />\n <StyledInput\n autoFocus\n value={query}\n onChange={(e) => {\n setQuery(e.target.value);\n setActiveIndex(0);\n }}\n onKeyDown={onKeyDown}\n placeholder=\"Search docs...\"\n autoComplete=\"off\"\n spellCheck={false}\n />\n <IconButton\n onClick={closeSearch}\n aria-label=\"Close search\"\n title=\"Close search\"\n >\n <X />\n </IconButton>\n </StyledInputWrapper>\n {merged.length > 0 ? (\n <StyledResults ref={resultsRef}>\n {merged.map((result, index) => (\n <StyledResultItem\n key={result.page.slug + result.page.section}\n $isActive={index === activeIndex}\n onClick={() => navigate(result.page.slug)}\n onMouseEnter={() => setActiveIndex(index)}\n >\n <StyledResultTitle>{result.page.title}</StyledResultTitle>\n <StyledResultMeta>\n {result.page.section\n ? `${sectionLabels[result.page.section] || result.page.section} / `\n : \"\"}\n {result.page.category}\n </StyledResultMeta>\n {result.snippet && (\n <StyledSnippet\n dangerouslySetInnerHTML={{\n __html: highlightMatch(result.snippet, query),\n }}\n />\n )}\n </StyledResultItem>\n ))}\n </StyledResults>\n ) : (\n <StyledEmpty>\n {isSearching ? <Spinner size={18} /> : \"No results found\"}\n </StyledEmpty>\n )}\n </StyledModal>\n </StyledBackdrop>\n );\n}\n";
@@ -1,7 +1,8 @@
1
1
  export const searchModalContentTemplate = `"use client";
2
2
  import React from "react";
3
3
  import styled, { css, keyframes } from "styled-components";
4
- import { Search } from "lucide-react";
4
+ import { Search, X } from "lucide-react";
5
+ import { IconButton } from "cherry-styled-components";
5
6
  import { mq, Theme } from "@/app/theme";
6
7
  import { Spinner } from "@/components/Spinner";
7
8
  import { thinScrollbar } from "@/components/layout/SharedStyled";
@@ -35,7 +36,7 @@ export interface SearchModalContentProps {
35
36
  navigate: (slug: string) => void;
36
37
  }
37
38
 
38
- const ANIMATION_MS = 150;
39
+ const ANIMATION_MS = 300;
39
40
 
40
41
  const backdropIn = keyframes\`
41
42
  from { opacity: 0; }
@@ -135,6 +136,15 @@ const StyledResults = styled.ul<{ theme: Theme }>\`
135
136
  min-height: 0;
136
137
  -webkit-overflow-scrolling: touch;
137
138
  \${thinScrollbar};
139
+
140
+ /* The results list is a keyboard-focusable scroll container; frame it with
141
+ the app focus ring (inset so it hugs the visible viewport and isn't clipped
142
+ by the scroll overflow) instead of the browser's default outline. */
143
+ &:focus-visible {
144
+ outline: none;
145
+ border-radius: \${({ theme }) => theme.spacing.radius.xs};
146
+ box-shadow: inset 0 0 0 2px \${({ theme }) => theme.colors.primaryLight};
147
+ }
138
148
  \`;
139
149
 
140
150
  const StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>\`
@@ -203,22 +213,6 @@ const StyledEmpty = styled.div<{ theme: Theme }>\`
203
213
  color: \${({ theme }) => theme.colors.gray};
204
214
  \`;
205
215
 
206
- const StyledKbd = styled.kbd<{ theme: Theme }>\`
207
- font-size: 11px;
208
- font-family: inherit;
209
- background: \${({ theme }) => theme.colors.grayLight};
210
- color: \${({ theme }) => theme.colors.grayDark};
211
- padding: 2px 6px;
212
- border-radius: 4px;
213
- margin-left: auto;
214
- font-weight: 600;
215
- display: none;
216
-
217
- \${mq("lg")} {
218
- display: initial;
219
- }
220
- \`;
221
-
222
216
  function escapeHtml(str: string): string {
223
217
  return str
224
218
  .replace(/&/g, "&amp;")
@@ -257,7 +251,14 @@ export function SearchModalContent({
257
251
  <StyledBackdrop
258
252
  $isClosing={isClosing}
259
253
  onClick={closeSearch}
260
- onAnimationEnd={onCloseAnimationEnd}
254
+ onAnimationEnd={(e) => {
255
+ // animationend bubbles, so this handler also fires for animations
256
+ // completing on descendants (the inner modal, icons, result items).
257
+ // Only the backdrop's own exit animation should trigger the unmount -
258
+ // otherwise a child finishing its animation first tears the modal down
259
+ // before the close animation plays, making it vanish instantly.
260
+ if (e.target === e.currentTarget) onCloseAnimationEnd();
261
+ }}
261
262
  >
262
263
  <StyledModal $isClosing={isClosing} onClick={(e) => e.stopPropagation()}>
263
264
  <StyledInputWrapper>
@@ -274,7 +275,13 @@ export function SearchModalContent({
274
275
  autoComplete="off"
275
276
  spellCheck={false}
276
277
  />
277
- <StyledKbd>Esc</StyledKbd>
278
+ <IconButton
279
+ onClick={closeSearch}
280
+ aria-label="Close search"
281
+ title="Close search"
282
+ >
283
+ <X />
284
+ </IconButton>
278
285
  </StyledInputWrapper>
279
286
  {merged.length > 0 ? (
280
287
  <StyledResults ref={resultsRef}>
@@ -1 +1 @@
1
- export declare const accordionTemplate = "\"use client\";\nimport { useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { styledText } from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { Icon } from \"@/components/layout/Icon\";\n\nconst StyledAccordion = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n width: 100%;\n`;\n\nconst StyledAccordionTitle = styled.h3<{ theme: Theme; $isOpen: boolean }>`\n cursor: pointer;\n margin: 0;\n padding: 0 40px 0 0;\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.primary};\n transition: color 0.3s ease;\n position: relative;\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n }\n\n & .lucide-chevron-down {\n position: absolute;\n top: 50%;\n transform: translateY(-50%);\n right: 0;\n transition: transform 0.3s ease;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n transform: translateY(-50%) rotate(180deg);\n `}\n }\n`;\n\nconst StyledAccordionContent = styled.div<{ theme: Theme; $isOpen: boolean }>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.grayDark};\n height: 0;\n overflow: clip;\n transition: all 0.3s ease;\n display: flex;\n flex-direction: column;\n gap: 20px;\n flex-wrap: wrap;\n flex: 1;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n margin: 20px 0 0;\n height: auto;\n `}\n`;\n\ninterface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n title: string;\n}\n\nfunction Accordion({ children, title }: AccordionProps) {\n const [isOpen, setIsOpen] = useState(false);\n\n return (\n <StyledAccordion>\n <StyledAccordionTitle\n onClick={() => setIsOpen(!isOpen)}\n $isOpen={isOpen}\n role=\"button\"\n aria-expanded={isOpen}\n >\n {title} <Icon name=\"ChevronDown\" />\n </StyledAccordionTitle>\n <StyledAccordionContent $isOpen={isOpen}>\n {children}\n </StyledAccordionContent>\n </StyledAccordion>\n );\n}\n\nexport { Accordion };\n";
1
+ export declare const accordionTemplate = "\"use client\";\nimport { useId, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { styledText } from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { Icon } from \"@/components/layout/Icon\";\n\nconst StyledAccordion = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n width: 100%;\n`;\n\nconst StyledAccordionTitle = styled.button<{ theme: Theme; $isOpen: boolean }>`\n appearance: none;\n display: block;\n width: 100%;\n border: none;\n background: none;\n text-align: left;\n font: inherit;\n cursor: pointer;\n margin: 0;\n padding: 0 40px 0 0;\n ${({ theme }) => styledText(theme)};\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n transition: color 0.3s ease;\n position: relative;\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n }\n\n & .lucide-chevron-down {\n position: absolute;\n top: 50%;\n transform: translateY(-50%);\n right: 0;\n transition: transform 0.3s ease;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n transform: translateY(-50%) rotate(180deg);\n `}\n }\n`;\n\nconst StyledAccordionContent = styled.div<{ theme: Theme; $isOpen: boolean }>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.grayDark};\n height: 0;\n overflow: clip;\n transition: all 0.3s ease;\n display: flex;\n flex-direction: column;\n gap: 20px;\n flex-wrap: wrap;\n flex: 1;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n margin: 20px 0 0;\n height: auto;\n `}\n`;\n\ninterface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n title: string;\n}\n\nfunction Accordion({ children, title }: AccordionProps) {\n const [isOpen, setIsOpen] = useState(false);\n const contentId = useId();\n\n return (\n <StyledAccordion>\n <StyledAccordionTitle\n type=\"button\"\n onClick={() => setIsOpen(!isOpen)}\n $isOpen={isOpen}\n aria-expanded={isOpen}\n aria-controls={contentId}\n >\n {title} <Icon name=\"ChevronDown\" />\n </StyledAccordionTitle>\n <StyledAccordionContent id={contentId} role=\"region\" $isOpen={isOpen}>\n {children}\n </StyledAccordionContent>\n </StyledAccordion>\n );\n}\n\nexport { Accordion };\n";
@@ -1,5 +1,5 @@
1
1
  export const accordionTemplate = `"use client";
2
- import { useState } from "react";
2
+ import { useId, useState } from "react";
3
3
  import styled, { css } from "styled-components";
4
4
  import { styledText } from "cherry-styled-components";
5
5
  import { Theme } from "@/app/theme";
@@ -15,11 +15,19 @@ const StyledAccordion = styled.div<{ theme: Theme }>\`
15
15
  width: 100%;
16
16
  \`;
17
17
 
18
- const StyledAccordionTitle = styled.h3<{ theme: Theme; $isOpen: boolean }>\`
18
+ const StyledAccordionTitle = styled.button<{ theme: Theme; $isOpen: boolean }>\`
19
+ appearance: none;
20
+ display: block;
21
+ width: 100%;
22
+ border: none;
23
+ background: none;
24
+ text-align: left;
25
+ font: inherit;
19
26
  cursor: pointer;
20
27
  margin: 0;
21
28
  padding: 0 40px 0 0;
22
29
  \${({ theme }) => styledText(theme)};
30
+ font-weight: 700;
23
31
  color: \${({ theme }) => theme.colors.primary};
24
32
  transition: color 0.3s ease;
25
33
  position: relative;
@@ -70,18 +78,20 @@ interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
70
78
 
71
79
  function Accordion({ children, title }: AccordionProps) {
72
80
  const [isOpen, setIsOpen] = useState(false);
81
+ const contentId = useId();
73
82
 
74
83
  return (
75
84
  <StyledAccordion>
76
85
  <StyledAccordionTitle
86
+ type="button"
77
87
  onClick={() => setIsOpen(!isOpen)}
78
88
  $isOpen={isOpen}
79
- role="button"
80
89
  aria-expanded={isOpen}
90
+ aria-controls={contentId}
81
91
  >
82
92
  {title} <Icon name="ChevronDown" />
83
93
  </StyledAccordionTitle>
84
- <StyledAccordionContent $isOpen={isOpen}>
94
+ <StyledAccordionContent id={contentId} role="region" $isOpen={isOpen}>
85
95
  {children}
86
96
  </StyledAccordionContent>
87
97
  </StyledAccordion>
@@ -1 +1 @@
1
- export declare const buttonTemplate = "\"use client\";\nimport Link from \"next/link\";\nimport styled from \"styled-components\";\nimport {\n theme as localTheme,\n ButtonProps,\n buttonStyles,\n} from \"cherry-styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\n\ninterface LinkButtonProps extends ButtonProps {\n href?: string;\n target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n rel?: string;\n variant?: \"primary\" | \"secondary\" | \"tertiary\";\n size?: \"default\" | \"big\";\n outline?: boolean;\n fullWidth?: boolean;\n icon?: string;\n iconPosition?: \"left\" | \"right\";\n theme?: typeof localTheme;\n}\n\n// Cherry's buttonStyles picks filled-button text via `isDark ? colors.dark : colors.light`.\n// Our theme's isDark is a stub (false) because mode switching lives in CSS vars, so\n// the fallback resolves to --color-light (black in dark mode). Re-pin to `surface`,\n// which resolves to white in both modes, for the filled, non-disabled case.\nconst StyledLinkButton = styled(Link)<LinkButtonProps>`\n ${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>\n buttonStyles(\n theme,\n $variant,\n $size,\n $outline,\n $fullWidth,\n $error,\n disabled,\n )}\n\n ${({ theme, $outline, disabled }) =>\n !disabled && !$outline && `color: ${theme.colors.surface};`}\n\n & p {\n color: inherit !important;\n }\n\n & svg.lucide {\n margin: auto 0;\n min-width: min-content;\n color: inherit;\n }\n`;\n\nconst ButtonBase = styled.button<ButtonProps>`\n ${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>\n buttonStyles(\n theme,\n $variant,\n $size,\n $outline,\n $fullWidth,\n $error,\n disabled,\n )}\n\n ${({ theme, $outline, disabled }) =>\n !disabled && !$outline && `color: ${theme.colors.surface};`}\n\n & p {\n color: inherit !important;\n }\n\n & svg.lucide {\n margin: auto 0;\n min-width: min-content;\n color: inherit;\n }\n`;\n\nfunction Button({\n variant = \"primary\",\n size,\n outline,\n fullWidth,\n icon,\n iconPosition = \"left\",\n theme: _theme = localTheme,\n href,\n target,\n rel,\n ...props\n}: LinkButtonProps) {\n const isExternal = typeof href === \"string\" && /^(https?:)?\\/\\//i.test(href);\n const resolvedTarget = target ?? (isExternal ? \"_blank\" : undefined);\n const resolvedRel =\n rel ?? (resolvedTarget === \"_blank\" ? \"noopener noreferrer\" : undefined);\n return href ? (\n <div>\n <StyledLinkButton\n {...props}\n href={href}\n target={resolvedTarget}\n rel={resolvedRel}\n $variant={variant}\n $size={size}\n $outline={outline}\n $fullWidth={fullWidth}\n >\n {iconPosition === \"left\" && icon && <Icon name={icon} size={16} />}\n {props.children}\n {iconPosition === \"right\" && icon && <Icon name={icon} size={16} />}\n </StyledLinkButton>\n </div>\n ) : (\n <div>\n <ButtonBase\n {...props}\n $variant={variant}\n $size={size}\n $outline={outline}\n $fullWidth={fullWidth}\n >\n {iconPosition === \"left\" && icon && <Icon name={icon} size={16} />}\n {props.children}\n {iconPosition === \"right\" && icon && <Icon name={icon} size={16} />}\n </ButtonBase>\n </div>\n );\n}\n\nexport { Button };\n";
1
+ export declare const buttonTemplate = "\"use client\";\nimport Link from \"next/link\";\nimport styled from \"styled-components\";\nimport {\n theme as localTheme,\n ButtonProps,\n buttonStyles,\n} from \"cherry-styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\n\ninterface LinkButtonProps extends ButtonProps {\n href?: string;\n target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n rel?: string;\n variant?: \"primary\" | \"secondary\" | \"tertiary\";\n size?: \"default\" | \"big\";\n outline?: boolean;\n fullWidth?: boolean;\n icon?: string;\n iconPosition?: \"left\" | \"right\";\n theme?: typeof localTheme;\n}\n\n// Cherry's buttonStyles picks filled-button text via `isDark ? colors.dark : colors.light`.\n// Our theme's isDark is a stub (false) because mode switching lives in CSS vars, so\n// the fallback resolves to --color-light (black in dark mode). Re-pin to `surface`,\n// which resolves to white in both modes, for the filled, non-disabled case.\n// The \"button-link\" class opts this anchor out of the global a:focus-visible\n// ring (see GlobalStyles) so Cherry's buttonStyles owns the button's focus look.\nconst StyledLinkButton = styled(Link).attrs({\n className: \"button-link\",\n})<LinkButtonProps>`\n ${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>\n buttonStyles(\n theme,\n $variant,\n $size,\n $outline,\n $fullWidth,\n $error,\n disabled,\n )}\n\n ${({ theme, $outline, disabled }) =>\n !disabled && !$outline && `color: ${theme.colors.surface};`}\n\n & p {\n color: inherit !important;\n }\n\n & svg.lucide {\n margin: auto 0;\n min-width: min-content;\n color: inherit;\n }\n`;\n\nconst ButtonBase = styled.button<ButtonProps>`\n ${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>\n buttonStyles(\n theme,\n $variant,\n $size,\n $outline,\n $fullWidth,\n $error,\n disabled,\n )}\n\n ${({ theme, $outline, disabled }) =>\n !disabled && !$outline && `color: ${theme.colors.surface};`}\n\n & p {\n color: inherit !important;\n }\n\n & svg.lucide {\n margin: auto 0;\n min-width: min-content;\n color: inherit;\n }\n`;\n\nfunction Button({\n variant = \"primary\",\n size,\n outline,\n fullWidth,\n icon,\n iconPosition = \"left\",\n theme: _theme = localTheme,\n href,\n target,\n rel,\n ...props\n}: LinkButtonProps) {\n const isExternal = typeof href === \"string\" && /^(https?:)?\\/\\//i.test(href);\n const resolvedTarget = target ?? (isExternal ? \"_blank\" : undefined);\n const resolvedRel =\n rel ?? (resolvedTarget === \"_blank\" ? \"noopener noreferrer\" : undefined);\n return href ? (\n <div>\n <StyledLinkButton\n {...props}\n href={href}\n target={resolvedTarget}\n rel={resolvedRel}\n $variant={variant}\n $size={size}\n $outline={outline}\n $fullWidth={fullWidth}\n >\n {iconPosition === \"left\" && icon && <Icon name={icon} size={16} />}\n {props.children}\n {iconPosition === \"right\" && icon && <Icon name={icon} size={16} />}\n </StyledLinkButton>\n </div>\n ) : (\n <div>\n <ButtonBase\n {...props}\n $variant={variant}\n $size={size}\n $outline={outline}\n $fullWidth={fullWidth}\n >\n {iconPosition === \"left\" && icon && <Icon name={icon} size={16} />}\n {props.children}\n {iconPosition === \"right\" && icon && <Icon name={icon} size={16} />}\n </ButtonBase>\n </div>\n );\n}\n\nexport { Button };\n";
@@ -25,7 +25,11 @@ interface LinkButtonProps extends ButtonProps {
25
25
  // Our theme's isDark is a stub (false) because mode switching lives in CSS vars, so
26
26
  // the fallback resolves to --color-light (black in dark mode). Re-pin to \`surface\`,
27
27
  // which resolves to white in both modes, for the filled, non-disabled case.
28
- const StyledLinkButton = styled(Link)<LinkButtonProps>\`
28
+ // The "button-link" class opts this anchor out of the global a:focus-visible
29
+ // ring (see GlobalStyles) so Cherry's buttonStyles owns the button's focus look.
30
+ const StyledLinkButton = styled(Link).attrs({
31
+ className: "button-link",
32
+ })<LinkButtonProps>\`
29
33
  \${({ theme, $variant, $size, $outline, $fullWidth, $error, disabled }) =>
30
34
  buttonStyles(
31
35
  theme,
@@ -1 +1 @@
1
- export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo, useId } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n interactiveStyles,\n resetButton,\n styledCode,\n} from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n title?: string;\n theme?: Theme;\n}\n\ninterface CodeTab {\n label: string;\n code: string;\n language?: string;\n}\n\ninterface CodeTabsProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n tabs: CodeTab[];\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n border-color: rgba(255, 255, 255, 0.2);\n }\n`;\n\n/* Code block uses a fixed GitHub-style palette in both modes. Independent of\n theme.json so syntax highlighting stays legible regardless of brand colors.\n Dark variants live in :root.dark & blocks so the swap happens via the\n active <html> class with no re-render. */\nconst TopBar = styled.div<{ theme: Theme }>`\n position: relative;\n background: #f6f8fa;\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px rgba(0, 0, 0, 0.1);\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n\n :root.dark & {\n background: #0d1117;\n border-bottom-color: rgba(255, 255, 255, 0.1);\n }\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n background: rgba(255, 255, 255, 0.1);\n }\n`;\n\n/* Icon-only copy button. interactiveStyles supplies the border highlight on\n hover plus the focus/active rings (no scale effect); the GitHub-style\n copied/base colors stay fixed like the rest of the code block. The dark\n block re-declares the hover border because its higher-specificity\n :root.dark & border-color would otherwise override the mixin's hover. */\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n ${resetButton}\n ${interactiveStyles}\n background: ${({ $copied }) =>\n $copied ? \"rgba(45, 164, 78, 0.1)\" : \"transparent\"};\n border-color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"rgba(0, 0, 0, 0.1)\")};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"#57606a\")};\n }\n\n :root.dark & {\n background: ${({ $copied }) =>\n $copied ? \"rgba(126, 231, 135, 0.2)\" : \"transparent\"};\n border-color: ${({ $copied }) =>\n $copied ? \"#7ee787\" : \"rgba(255, 255, 255, 0.1)\"};\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#7ee787\" : \"#c9d1d9\")};\n }\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n`;\n\n/* Centered file name in the TopBar. Sits absolutely over the flex row so the\n dots and copy button keep their edge alignment. Monospace + muted GitHub\n grays, swapped via :root.dark like the rest of the block. pointer-events off\n so clicks fall through to nothing and never steal focus from the buttons. */\nconst TopBarTitle = styled.span<{ theme: Theme }>`\n position: absolute;\n left: 50%;\n transform: translateX(-50%);\n max-width: 60%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n pointer-events: none;\n font-family: ${({ theme }) => theme.fonts.mono};\n font-size: 12px;\n line-height: 2;\n color: #57606a;\n\n :root.dark & {\n color: #8b949e;\n }\n`;\n\n/* Segmented control living in the TopBar for CodeTabs. Scrolls horizontally\n with the thin scrollbar when the labels overflow the 33px bar. */\nconst TabList = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 2px;\n min-width: 0;\n overflow-x: auto;\n ${thinScrollbar};\n margin-left: -6px;\n`;\n\n/* Individual tab button. Active tab reads as part of the window: a light body\n fill (#ffffff) that echoes the code area, with a soft border. Inactive tabs\n are muted and transparent. resetButton strips native styling; focus-visible\n uses the brand primary ring for keyboard users. All colors stay fixed and\n swap purely via :root.dark, matching the no-re-render palette approach. */\nconst CodeTab = styled.button<{ theme: Theme; $active: boolean }>`\n ${resetButton}\n flex: 0 0 auto;\n font-family: ${({ theme }) => theme.fonts.mono};\n font-size: 12px;\n line-height: 1;\n padding: 5px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n white-space: nowrap;\n transition:\n color 0.15s ease,\n background 0.15s ease,\n border-color 0.15s ease;\n border: solid 1px\n ${({ $active }) => ($active ? \"rgba(0, 0, 0, 0.1)\" : \"transparent\")};\n background: ${({ $active }) => ($active ? \"#ffffff\" : \"transparent\")};\n color: ${({ $active }) => ($active ? \"#24292f\" : \"#57606a\")};\n\n &:hover {\n color: #24292f;\n background: ${({ $active }) =>\n $active ? \"#ffffff\" : \"rgba(0, 0, 0, 0.04)\"};\n }\n\n &:focus-visible {\n outline: solid 2px ${({ theme }) => theme.colors.primary};\n outline-offset: 1px;\n }\n\n :root.dark & {\n border-color: ${({ $active }) =>\n $active ? \"rgba(255, 255, 255, 0.15)\" : \"transparent\"};\n background: ${({ $active }) => ($active ? \"#161b22\" : \"transparent\")};\n color: ${({ $active }) => ($active ? \"#e6edf3\" : \"#8b949e\")};\n\n &:hover {\n color: #e6edf3;\n background: ${({ $active }) =>\n $active ? \"#161b22\" : \"rgba(255, 255, 255, 0.06)\"};\n }\n }\n`;\n\n/* GitHub Light syntax highlighting by default; GitHub Dark in :root.dark.\n Browser resolves which rule wins based on the active <html> class with no\n JS or React re-render involved. */\nconst lightSyntaxHighlight = css`\n & .hljs {\n color: #24292f;\n background: #ffffff;\n min-width: min-content;\n width: 100%;\n display: block;\n padding-right: 20px;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n & .hljs-subst {\n color: #24292f;\n }\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #953800;\n }\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n`;\n\nconst darkSyntaxHighlight = css`\n & .hljs {\n color: #c9d1d9;\n background: #0d1117;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n & .hljs-subst {\n color: #c9d1d9;\n }\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #f2cc60;\n }\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: #ffffff;\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: #24292f;\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100dvh - 400px);\n ${thinScrollbar};\n ${({ theme }) => styledCode(theme)};\n ${lightSyntaxHighlight};\n\n /* Diff lines: highlight.js wraps each +/- line in a single span, so\n stretching it to the full row reads like a GitHub diff. inline-block keeps\n the trailing newline as the line break instead of adding an empty row. */\n & .hljs-addition,\n & .hljs-deletion {\n display: inline-table;\n width: 100%;\n }\n\n :root.dark & {\n background: #0d1117;\n color: #ffffff;\n ${darkSyntaxHighlight};\n }\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n};\n\nconst sanitizeLanguage = (lang: string): string =>\n lang.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const safeLang = sanitizeLanguage(language);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${safeLang}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({\n code,\n language = \"javascript\",\n title,\n theme,\n className,\n}: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = useMemo(\n () => highlightCode(code, language),\n [code, language],\n );\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n {title ? <TopBarTitle theme={theme}>{title}</TopBarTitle> : null}\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\n/* Multi-variant code block (npm / pnpm / yarn, etc). Tabs replace the macOS\n dots in the TopBar as a keyboard-accessible tablist; the copy button copies\n whichever tab is active and resets its copied state when the tab changes. */\nfunction CodeTabs({ tabs, theme, className }: CodeTabsProps) {\n const baseId = useId();\n const [active, setActive] = useState(0);\n const [copied, setCopied] = useState(false);\n\n const safeActive = active < tabs.length ? active : 0;\n const activeTab = tabs[safeActive];\n\n const highlightedCode = useMemo(\n () =>\n activeTab\n ? highlightCode(activeTab.code, activeTab.language ?? \"bash\")\n : \"\",\n [activeTab],\n );\n\n const handleSelect = useCallback((index: number) => {\n setActive(index);\n setCopied(false);\n }, []);\n\n const handleCopy = useCallback(async () => {\n if (!activeTab) return;\n try {\n await navigator.clipboard.writeText(activeTab.code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [activeTab]);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLButtonElement>) => {\n let next = safeActive;\n if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n next = (safeActive + 1) % tabs.length;\n } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n next = (safeActive - 1 + tabs.length) % tabs.length;\n } else if (event.key === \"Home\") {\n next = 0;\n } else if (event.key === \"End\") {\n next = tabs.length - 1;\n } else {\n return;\n }\n event.preventDefault();\n handleSelect(next);\n const target = document.getElementById(`${baseId}-tab-${next}`);\n if (target) target.focus();\n },\n [baseId, handleSelect, safeActive, tabs.length],\n );\n\n if (!tabs || tabs.length === 0) return null;\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <TabList role=\"tablist\" aria-label=\"Code variants\">\n {tabs.map((tab, index) => (\n <CodeTab\n key={`${tab.label}-${index}`}\n type=\"button\"\n role=\"tab\"\n id={`${baseId}-tab-${index}`}\n aria-selected={index === safeActive}\n aria-controls={`${baseId}-panel`}\n tabIndex={index === safeActive ? 0 : -1}\n onClick={() => handleSelect(index)}\n onKeyDown={handleKeyDown}\n $active={index === safeActive}\n theme={theme}\n >\n {tab.label}\n </CodeTab>\n ))}\n </TabList>\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n id={`${baseId}-panel`}\n role=\"tabpanel\"\n aria-labelledby={`${baseId}-tab-${safeActive}`}\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code, CodeTabs };\n";
1
+ export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo, useId } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n interactiveStyles,\n resetButton,\n styledCode,\n} from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n title?: string;\n theme?: Theme;\n}\n\ninterface CodeTab {\n label: string;\n code: string;\n language?: string;\n}\n\ninterface CodeTabsProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n tabs: CodeTab[];\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n border-color: rgba(255, 255, 255, 0.2);\n }\n`;\n\n/* Code block uses a fixed GitHub-style palette in both modes. Independent of\n theme.json so syntax highlighting stays legible regardless of brand colors.\n Dark variants live in :root.dark & blocks so the swap happens via the\n active <html> class with no re-render. */\nconst TopBar = styled.div<{ theme: Theme }>`\n position: relative;\n background: #f6f8fa;\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px rgba(0, 0, 0, 0.1);\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n\n :root.dark & {\n background: #0d1117;\n border-bottom-color: rgba(255, 255, 255, 0.1);\n }\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n background: rgba(255, 255, 255, 0.1);\n }\n`;\n\n/* Icon-only copy button. interactiveStyles supplies the border highlight on\n hover plus the focus/active rings (no scale effect); the GitHub-style\n copied/base colors stay fixed like the rest of the code block. The dark\n block re-declares the hover border because its higher-specificity\n :root.dark & border-color would otherwise override the mixin's hover. */\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n ${resetButton}\n ${interactiveStyles}\n background: ${({ $copied }) =>\n $copied ? \"rgba(45, 164, 78, 0.1)\" : \"transparent\"};\n border-color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"rgba(0, 0, 0, 0.1)\")};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"#57606a\")};\n }\n\n :root.dark & {\n background: ${({ $copied }) =>\n $copied ? \"rgba(126, 231, 135, 0.2)\" : \"transparent\"};\n border-color: ${({ $copied }) =>\n $copied ? \"#7ee787\" : \"rgba(255, 255, 255, 0.1)\"};\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#7ee787\" : \"#c9d1d9\")};\n }\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n`;\n\n/* Centered file name in the TopBar. Sits absolutely over the flex row so the\n dots and copy button keep their edge alignment. Monospace + muted GitHub\n grays, swapped via :root.dark like the rest of the block. pointer-events off\n so clicks fall through to nothing and never steal focus from the buttons. */\nconst TopBarTitle = styled.span<{ theme: Theme }>`\n position: absolute;\n left: 50%;\n transform: translateX(-50%);\n max-width: 60%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n pointer-events: none;\n font-family: ${({ theme }) => theme.fonts.mono};\n font-size: 12px;\n line-height: 2;\n color: #57606a;\n\n :root.dark & {\n color: #8b949e;\n }\n`;\n\n/* Segmented control living in the TopBar for CodeTabs. Scrolls horizontally\n with the thin scrollbar when the labels overflow the 33px bar. */\nconst TabList = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 2px;\n min-width: 0;\n overflow-x: auto;\n ${thinScrollbar};\n margin-left: -6px;\n`;\n\n/* Individual tab button. Active tab reads as part of the window: a light body\n fill (#ffffff) that echoes the code area, with a soft border. Inactive tabs\n are muted and transparent. resetButton strips native styling; focus-visible\n draws an inset brand primary ring on a pseudo-element so the scrolling\n TabList can't clip it. All colors stay fixed and swap purely via :root.dark,\n matching the no-re-render palette approach. */\nconst CodeTab = styled.button<{ theme: Theme; $active: boolean }>`\n ${resetButton}\n flex: 0 0 auto;\n position: relative;\n font-family: ${({ theme }) => theme.fonts.mono};\n font-size: 12px;\n line-height: 1;\n padding: 5px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n white-space: nowrap;\n transition:\n color 0.15s ease,\n background 0.15s ease,\n border-color 0.15s ease;\n border: solid 1px\n ${({ $active }) => ($active ? \"rgba(0, 0, 0, 0.1)\" : \"transparent\")};\n background: ${({ $active }) => ($active ? \"#ffffff\" : \"transparent\")};\n color: ${({ $active }) => ($active ? \"#24292f\" : \"#57606a\")};\n\n &:hover {\n color: #24292f;\n background: ${({ $active }) =>\n $active ? \"#ffffff\" : \"rgba(0, 0, 0, 0.04)\"};\n }\n\n &:focus-visible {\n outline: none;\n }\n\n &:focus-visible::after {\n content: \"\";\n position: absolute;\n inset: 2px;\n border: solid 2px ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n pointer-events: none;\n }\n\n :root.dark & {\n border-color: ${({ $active }) =>\n $active ? \"rgba(255, 255, 255, 0.15)\" : \"transparent\"};\n background: ${({ $active }) => ($active ? \"#161b22\" : \"transparent\")};\n color: ${({ $active }) => ($active ? \"#e6edf3\" : \"#8b949e\")};\n\n &:hover {\n color: #e6edf3;\n background: ${({ $active }) =>\n $active ? \"#161b22\" : \"rgba(255, 255, 255, 0.06)\"};\n }\n }\n`;\n\n/* GitHub Light syntax highlighting by default; GitHub Dark in :root.dark.\n Browser resolves which rule wins based on the active <html> class with no\n JS or React re-render involved. */\nconst lightSyntaxHighlight = css`\n & .hljs {\n color: #24292f;\n background: #ffffff;\n min-width: min-content;\n width: 100%;\n display: block;\n padding-right: 20px;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n & .hljs-subst {\n color: #24292f;\n }\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #953800;\n }\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n`;\n\nconst darkSyntaxHighlight = css`\n & .hljs {\n color: #c9d1d9;\n background: #0d1117;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n & .hljs-subst {\n color: #c9d1d9;\n }\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #f2cc60;\n }\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: #ffffff;\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: #24292f;\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100dvh - 400px);\n ${thinScrollbar};\n ${({ theme }) => styledCode(theme)};\n ${lightSyntaxHighlight};\n\n /* Diff lines: highlight.js wraps each +/- line in a single span, so\n stretching it to the full row reads like a GitHub diff. inline-block keeps\n the trailing newline as the line break instead of adding an empty row. */\n & .hljs-addition,\n & .hljs-deletion {\n display: inline-table;\n width: 100%;\n }\n\n :root.dark & {\n background: #0d1117;\n color: #ffffff;\n ${darkSyntaxHighlight};\n }\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n};\n\nconst sanitizeLanguage = (lang: string): string =>\n lang.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const safeLang = sanitizeLanguage(language);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${safeLang}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({\n code,\n language = \"javascript\",\n title,\n theme,\n className,\n}: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = useMemo(\n () => highlightCode(code, language),\n [code, language],\n );\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n {title ? <TopBarTitle theme={theme}>{title}</TopBarTitle> : null}\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\n/* Multi-variant code block (npm / pnpm / yarn, etc). Tabs replace the macOS\n dots in the TopBar as a keyboard-accessible tablist; the copy button copies\n whichever tab is active and resets its copied state when the tab changes. */\nfunction CodeTabs({ tabs, theme, className }: CodeTabsProps) {\n const baseId = useId();\n const [active, setActive] = useState(0);\n const [copied, setCopied] = useState(false);\n\n const safeActive = active < tabs.length ? active : 0;\n const activeTab = tabs[safeActive];\n\n const highlightedCode = useMemo(\n () =>\n activeTab\n ? highlightCode(activeTab.code, activeTab.language ?? \"bash\")\n : \"\",\n [activeTab],\n );\n\n const handleSelect = useCallback((index: number) => {\n setActive(index);\n setCopied(false);\n }, []);\n\n const handleCopy = useCallback(async () => {\n if (!activeTab) return;\n try {\n await navigator.clipboard.writeText(activeTab.code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [activeTab]);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLButtonElement>) => {\n let next = safeActive;\n if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n next = (safeActive + 1) % tabs.length;\n } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n next = (safeActive - 1 + tabs.length) % tabs.length;\n } else if (event.key === \"Home\") {\n next = 0;\n } else if (event.key === \"End\") {\n next = tabs.length - 1;\n } else {\n return;\n }\n event.preventDefault();\n handleSelect(next);\n const target = document.getElementById(`${baseId}-tab-${next}`);\n if (target) target.focus();\n },\n [baseId, handleSelect, safeActive, tabs.length],\n );\n\n if (!tabs || tabs.length === 0) return null;\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <TabList role=\"tablist\" aria-label=\"Code variants\">\n {tabs.map((tab, index) => (\n <CodeTab\n key={`${tab.label}-${index}`}\n type=\"button\"\n role=\"tab\"\n id={`${baseId}-tab-${index}`}\n aria-selected={index === safeActive}\n aria-controls={`${baseId}-panel`}\n tabIndex={index === safeActive ? 0 : -1}\n onClick={() => handleSelect(index)}\n onKeyDown={handleKeyDown}\n $active={index === safeActive}\n theme={theme}\n >\n {tab.label}\n </CodeTab>\n ))}\n </TabList>\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n id={`${baseId}-panel`}\n role=\"tabpanel\"\n aria-labelledby={`${baseId}-tab-${safeActive}`}\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code, CodeTabs };\n";