doccupine 0.0.95 → 0.0.97

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 (32) hide show
  1. package/dist/index.js +4 -1
  2. package/dist/lib/layout.js +30 -10
  3. package/dist/lib/structures.d.ts +1 -0
  4. package/dist/lib/structures.js +9 -6
  5. package/dist/templates/app/theme.d.ts +1 -1
  6. package/dist/templates/app/theme.js +36 -54
  7. package/dist/templates/components/Chat.d.ts +1 -1
  8. package/dist/templates/components/Chat.js +4 -0
  9. package/dist/templates/components/SearchDocs.d.ts +1 -1
  10. package/dist/templates/components/SearchDocs.js +26 -17
  11. package/dist/templates/components/SearchModalContent.d.ts +1 -1
  12. package/dist/templates/components/SearchModalContent.js +2 -4
  13. package/dist/templates/components/SideBar.d.ts +1 -1
  14. package/dist/templates/components/SideBar.js +3 -9
  15. package/dist/templates/components/layout/ActionBar.d.ts +1 -1
  16. package/dist/templates/components/layout/ActionBar.js +22 -27
  17. package/dist/templates/components/layout/CherryThemeProvider.d.ts +1 -1
  18. package/dist/templates/components/layout/CherryThemeProvider.js +26 -6
  19. package/dist/templates/components/layout/Code.d.ts +1 -1
  20. package/dist/templates/components/layout/Code.js +25 -38
  21. package/dist/templates/components/layout/DemoTheme.d.ts +1 -1
  22. package/dist/templates/components/layout/DemoTheme.js +45 -14
  23. package/dist/templates/components/layout/Header.d.ts +1 -1
  24. package/dist/templates/components/layout/Header.js +1 -1
  25. package/dist/templates/components/layout/SharedStyles.d.ts +1 -1
  26. package/dist/templates/components/layout/SharedStyles.js +9 -0
  27. package/dist/templates/components/layout/SiteGate.d.ts +1 -1
  28. package/dist/templates/components/layout/SiteGate.js +3 -9
  29. package/dist/templates/components/layout/Tabs.d.ts +1 -1
  30. package/dist/templates/components/layout/Tabs.js +2 -0
  31. package/dist/templates/package.js +2 -2
  32. package/package.json +1 -1
@@ -27,6 +27,10 @@ interface SectionItem {
27
27
  slug: string;
28
28
  }
29
29
 
30
+ // Stable empty array so the derived contentResults keeps the same identity
31
+ // across renders when there are no hits (memo deps stay stable).
32
+ const EMPTY_RESULTS: ContentHit[] = [];
33
+
30
34
  interface ContentHit {
31
35
  slug: string;
32
36
  snippet: string;
@@ -91,8 +95,14 @@ function SearchProvider({
91
95
  const [isClosing, setIsClosing] = useState(false);
92
96
  const [query, setQuery] = useState("");
93
97
  const [activeIndex, setActiveIndex] = useState(0);
94
- const [contentResults, setContentResults] = useState<ContentHit[]>([]);
95
- const [isSearching, setIsSearching] = useState(false);
98
+ // Latest completed content search, keyed by the query that produced it.
99
+ // contentResults and isSearching are derived from it at render time, so
100
+ // the debounced-search effect never sets state synchronously (which would
101
+ // trigger cascading renders — react-hooks/set-state-in-effect).
102
+ const [fetched, setFetched] = useState<{
103
+ q: string;
104
+ results: ContentHit[];
105
+ } | null>(null);
96
106
  const resultsRef = useRef<HTMLUListElement>(null);
97
107
  const closingRef = useRef(false);
98
108
  const abortRef = useRef<AbortController | null>(null);
@@ -127,10 +137,16 @@ function SearchProvider({
127
137
  setIsClosing(false);
128
138
  setQuery("");
129
139
  setActiveIndex(0);
130
- setContentResults([]);
131
- setIsSearching(false);
140
+ setFetched(null);
132
141
  }, []);
133
142
 
143
+ const trimmedQuery = query.trim();
144
+ const contentResults =
145
+ trimmedQuery.length >= 2 && fetched?.q === trimmedQuery
146
+ ? fetched.results
147
+ : EMPTY_RESULTS;
148
+ const isSearching = trimmedQuery.length >= 2 && fetched?.q !== trimmedQuery;
149
+
134
150
  // Instant title/description filtering
135
151
  const titleFiltered = useMemo(() => {
136
152
  if (!query.trim()) return pages;
@@ -168,19 +184,16 @@ function SearchProvider({
168
184
  return [...titleMatches, ...contentOnly];
169
185
  }, [pages, query, titleFiltered, contentResults]);
170
186
 
171
- // Debounced content search
187
+ // Debounced content search. State updates happen only inside the timeout's
188
+ // async callback; short queries need no reset because the derived values
189
+ // above ignore results whose query no longer matches.
172
190
  useEffect(() => {
173
191
  if (debounceRef.current) clearTimeout(debounceRef.current);
174
192
  if (abortRef.current) abortRef.current.abort();
175
193
 
176
194
  const q = query.trim();
177
- if (q.length < 2) {
178
- setContentResults([]);
179
- setIsSearching(false);
180
- return;
181
- }
195
+ if (q.length < 2) return;
182
196
 
183
- setIsSearching(true);
184
197
  debounceRef.current = setTimeout(async () => {
185
198
  const controller = new AbortController();
186
199
  abortRef.current = controller;
@@ -191,14 +204,10 @@ function SearchProvider({
191
204
  );
192
205
  if (!res.ok) throw new Error("Search failed");
193
206
  const data = await res.json();
194
- setContentResults(data.results ?? []);
207
+ setFetched({ q, results: data.results ?? [] });
195
208
  } catch (err: unknown) {
196
209
  if (err instanceof DOMException && err.name === "AbortError") return;
197
- setContentResults([]);
198
- } finally {
199
- if (!controller.signal.aborted) {
200
- setIsSearching(false);
201
- }
210
+ setFetched({ q, results: [] });
202
211
  }
203
212
  }, 300);
204
213
 
@@ -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\";\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\n &::-webkit-scrollbar {\n display: none;\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\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 } 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";
@@ -4,6 +4,7 @@ import styled, { css, keyframes } from "styled-components";
4
4
  import { Search } from "lucide-react";
5
5
  import { mq, Theme } from "@/app/theme";
6
6
  import { Spinner } from "@/components/Spinner";
7
+ import { thinScrollbar } from "@/components/layout/SharedStyled";
7
8
 
8
9
  export interface PageItem {
9
10
  slug: string;
@@ -133,10 +134,7 @@ const StyledResults = styled.ul<{ theme: Theme }>\`
133
134
  flex: 1;
134
135
  min-height: 0;
135
136
  -webkit-overflow-scrolling: touch;
136
-
137
- &::-webkit-scrollbar {
138
- display: none;
139
- }
137
+ \${thinScrollbar};
140
138
  \`;
141
139
 
142
140
  const StyledResultItem = styled.li<{ theme: Theme; $isActive: boolean }>\`
@@ -1 +1 @@
1
- export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useState, Suspense } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyledSidebarFooter,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport {\n ToggleTheme,\n ToggleThemeLoading,\n} from \"@/components/layout/ThemeToggle\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\n\ntype NavItem = {\n label: string;\n links: NavItemLink[];\n};\n\ntype NavItemLink = {\n slug: string;\n title: string;\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n\n useLockBodyScroll(isMobileMenuOpen);\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n aria-label={\n isMobileMenuOpen ? \"Close navigation menu\" : \"Open navigation menu\"\n }\n aria-expanded={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>{item.label}</StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => {\n return (\n <StyledSidebarListItem key={indexChild}>\n <StyledSidebarListItemLink\n href={`/${link.slug}`}\n $isActive={pathname === `/${link.slug}`}\n onClick={() => setIsMobileMenuOpen(false)}\n >\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n })}\n <li aria-hidden=\"true\">\n <Space $size={20} />\n </li>\n </StyledSidebarList>\n );\n })}\n <StyledSidebarFooter>\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <Suspense fallback={<ToggleThemeLoading />}>\n <ToggleTheme />\n </Suspense>\n </Flex>\n </StyledSidebarFooter>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
1
+ export declare const sideBarTemplate = "\"use client\";\nimport { useContext, useState } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport { Flex, Space, ThemeToggle } from \"cherry-styled-components\";\nimport {\n DocsSidebar,\n SectionBarContext,\n StyledSidebar,\n StyledSidebarList,\n StyledSidebarListItem,\n StyledStrong,\n StyledSidebarListItemLink,\n StyledSidebarFooter,\n StyleMobileBar,\n StyledMobileBurger,\n} from \"@/components/layout/DocsComponents\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\n\ntype NavItem = {\n label: string;\n links: NavItemLink[];\n};\n\ntype NavItemLink = {\n slug: string;\n title: string;\n};\n\ninterface SideBarProps {\n result: NavItem[];\n}\n\nfunction SideBar({ result }: SideBarProps) {\n const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n const pathname = usePathname();\n\n useLockBodyScroll(isMobileMenuOpen);\n\n return (\n <DocsSidebar>\n <StyleMobileBar\n onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}\n $isActive={isMobileMenuOpen}\n aria-label={\n isMobileMenuOpen ? \"Close navigation menu\" : \"Open navigation menu\"\n }\n aria-expanded={isMobileMenuOpen}\n >\n <StyledMobileBurger $isActive={isMobileMenuOpen} />\n </StyleMobileBar>\n\n <StyledSidebar\n $isActive={isMobileMenuOpen}\n $hasSectionBar={hasSectionBar}\n >\n {result &&\n result.map((item: NavItem, index: number) => {\n return (\n <StyledSidebarList key={index}>\n <StyledSidebarListItem>\n <StyledStrong>{item.label}</StyledStrong>{\" \"}\n </StyledSidebarListItem>\n <li>\n <Space $size={20} />\n </li>\n {item.links &&\n item.links.map((link: NavItemLink, indexChild: number) => {\n return (\n <StyledSidebarListItem key={indexChild}>\n <StyledSidebarListItemLink\n href={`/${link.slug}`}\n $isActive={pathname === `/${link.slug}`}\n onClick={() => setIsMobileMenuOpen(false)}\n >\n {link.title}\n </StyledSidebarListItemLink>\n </StyledSidebarListItem>\n );\n })}\n <li aria-hidden=\"true\">\n <Space $size={20} />\n </li>\n </StyledSidebarList>\n );\n })}\n <StyledSidebarFooter>\n <Flex $xsJustifyContent=\"flex-start\" $lgJustifyContent=\"flex-end\">\n <ThemeToggle />\n </Flex>\n </StyledSidebarFooter>\n </StyledSidebar>\n </DocsSidebar>\n );\n}\n\nexport { SideBar };\n";
@@ -1,7 +1,7 @@
1
1
  export const sideBarTemplate = `"use client";
2
- import { useContext, useState, Suspense } from "react";
2
+ import { useContext, useState } from "react";
3
3
  import { usePathname } from "next/navigation";
4
- import { Flex, Space } from "cherry-styled-components";
4
+ import { Flex, Space, ThemeToggle } from "cherry-styled-components";
5
5
  import {
6
6
  DocsSidebar,
7
7
  SectionBarContext,
@@ -14,10 +14,6 @@ import {
14
14
  StyleMobileBar,
15
15
  StyledMobileBurger,
16
16
  } from "@/components/layout/DocsComponents";
17
- import {
18
- ToggleTheme,
19
- ToggleThemeLoading,
20
- } from "@/components/layout/ThemeToggle";
21
17
  import { useLockBodyScroll } from "@/components/LockBodyScroll";
22
18
 
23
19
  type NavItem = {
@@ -90,9 +86,7 @@ function SideBar({ result }: SideBarProps) {
90
86
  })}
91
87
  <StyledSidebarFooter>
92
88
  <Flex $xsJustifyContent="flex-start" $lgJustifyContent="flex-end">
93
- <Suspense fallback={<ToggleThemeLoading />}>
94
- <ToggleTheme />
95
- </Suspense>
89
+ <ThemeToggle />
96
90
  </Flex>
97
91
  </StyledSidebarFooter>
98
92
  </StyledSidebar>
@@ -1 +1 @@
1
- export declare const actionBarTemplate = "\"use client\";\nimport { useContext, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { resetButton, Textarea } from \"cherry-styled-components\";\nimport { SectionBarContext } from \"@/components/layout/DocsComponents\";\nimport { StyledSmallButton } from \"@/components/layout/SharedStyled\";\n\ninterface ActionBarProps {\n children: React.ReactNode;\n content: string;\n}\n\nconst StyledActionBar = styled.div<{\n theme: Theme;\n $isChatOpen?: boolean;\n}>`\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n left: 0;\n padding: 12px 0;\n display: flex;\n justify-content: space-between;\n width: 100%;\n max-width: 640px;\n margin: auto;\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n padding: 12px 0;\n }\n`;\n\nconst StyledActionBarContent = styled.div`\n margin: auto 0;\n`;\n\nconst StyledCopyButton = styled(StyledSmallButton)<{\n theme: Theme;\n $copied: boolean;\n}>`\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.grayLight};\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n }\n`;\n\nconst StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>`\n ${resetButton}\n width: 56px;\n height: 32px;\n border-radius: 30px;\n display: flex;\n position: relative;\n margin: auto 0;\n transform: scale(1);\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ $isActive }) =>\n !$isActive &&\n css`\n transform: translateX(24px);\n `}\n }\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n margin: auto;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & .lucide-eye {\n transform: translateX(1px);\n }\n\n & .lucide-code-xml {\n transform: translateX(-1px);\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n transform: scale(1.05);\n color: ${({ theme }) => theme.colors.accent};\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.accent};\n }\n }\n\n &:active {\n transform: scale(0.97);\n }\n`;\n\nconst StyledContent = styled.div<{\n theme: Theme;\n $hasSectionBar?: boolean;\n}>`\n padding-top: 20px;\n transition: all 0.3s ease;\n\n & textarea {\n max-width: 640px;\n margin: auto;\n width: 100%;\n height: 100%;\n min-height: calc(\n 100vh - ${({ $hasSectionBar }) => ($hasSectionBar ? 202 : 160)}px\n );\n\n ${mq(\"lg\")} {\n min-height: calc(100vh - 159px);\n }\n }\n`;\n\nfunction ActionBar({ children, content }: ActionBarProps) {\n const [isView, setIsView] = useState(true);\n const [copied, setCopied] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n\n const handleCopyContent = async () => {\n try {\n await navigator.clipboard.writeText(content);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy:\", err);\n }\n };\n\n return (\n <>\n <StyledActionBar>\n <StyledCopyButton onClick={handleCopyContent} $copied={copied}>\n {copied ? (\n <>\n <Icon name=\"check\" size={16} />\n Copied!\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={16} />\n Copy content\n </>\n )}\n </StyledCopyButton>\n <StyledActionBarContent>\n <StyledToggle\n onClick={() => setIsView(!isView)}\n aria-label=\"Toggle View\"\n $isActive={isView}\n >\n <Icon name=\"Eye\" />\n <Icon name=\"CodeXml\" />\n </StyledToggle>\n </StyledActionBarContent>\n </StyledActionBar>\n {isView && (\n <StyledContent $hasSectionBar={hasSectionBar}>{children}</StyledContent>\n )}\n {!isView && (\n <StyledContent $hasSectionBar={hasSectionBar}>\n <Textarea defaultValue={content} $fullWidth />\n </StyledContent>\n )}\n </>\n );\n}\n\nexport { ActionBar };\n";
1
+ export declare const actionBarTemplate = "\"use client\";\nimport { useContext, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport {\n interactiveStyles,\n resetButton,\n Textarea,\n} from \"cherry-styled-components\";\nimport { SectionBarContext } from \"@/components/layout/DocsComponents\";\nimport { StyledSmallButton } from \"@/components/layout/SharedStyled\";\n\ninterface ActionBarProps {\n children: React.ReactNode;\n content: string;\n}\n\nconst StyledActionBar = styled.div<{\n theme: Theme;\n $isChatOpen?: boolean;\n}>`\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n left: 0;\n padding: 12px 0;\n display: flex;\n justify-content: space-between;\n width: 100%;\n max-width: 640px;\n margin: auto;\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n padding: 12px 0;\n }\n`;\n\nconst StyledActionBarContent = styled.div`\n margin: auto 0;\n`;\n\nconst StyledCopyButton = styled(StyledSmallButton)<{\n theme: Theme;\n $copied: boolean;\n}>`\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.grayLight};\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.primary};\n }\n`;\n\n// Mirrors the geometry and interaction of Cherry's ThemeToggle so the two\n// pills look identical side by side: interactiveStyles supplies the border\n// highlight + focus/active rings (no scale effect), and the space-between\n// layout with 6px padding puts each 16px icon's center exactly where the\n// 24px knob's center sits in its resting (left: 2px) and active\n// (translateX(26px)) positions.\nconst StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>`\n ${resetButton}\n ${interactiveStyles}\n width: 56px;\n height: 30px;\n border-radius: 30px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0 6px;\n position: relative;\n margin: auto 0;\n background: ${({ theme }) => theme.colors.light};\n border-color: ${({ theme }) => theme.colors.grayLight};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ $isActive }) =>\n !$isActive &&\n css`\n transform: translateX(26px);\n `}\n }\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover svg[stroke] {\n stroke: ${({ theme }) => theme.colors.accent};\n }\n`;\n\nconst StyledContent = styled.div<{\n theme: Theme;\n $hasSectionBar?: boolean;\n}>`\n padding-top: 20px;\n transition: all 0.3s ease;\n\n & textarea {\n max-width: 640px;\n margin: auto;\n width: 100%;\n height: 100%;\n min-height: calc(\n 100vh - ${({ $hasSectionBar }) => ($hasSectionBar ? 202 : 160)}px\n );\n\n ${mq(\"lg\")} {\n min-height: calc(100vh - 159px);\n }\n }\n`;\n\nfunction ActionBar({ children, content }: ActionBarProps) {\n const [isView, setIsView] = useState(true);\n const [copied, setCopied] = useState(false);\n const hasSectionBar = useContext(SectionBarContext);\n\n const handleCopyContent = async () => {\n try {\n await navigator.clipboard.writeText(content);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy:\", err);\n }\n };\n\n return (\n <>\n <StyledActionBar>\n <StyledCopyButton onClick={handleCopyContent} $copied={copied}>\n {copied ? (\n <>\n <Icon name=\"check\" size={16} />\n Copied!\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={16} />\n Copy content\n </>\n )}\n </StyledCopyButton>\n <StyledActionBarContent>\n <StyledToggle\n onClick={() => setIsView(!isView)}\n aria-label=\"Toggle View\"\n $isActive={isView}\n >\n <Icon name=\"Eye\" />\n <Icon name=\"CodeXml\" />\n </StyledToggle>\n </StyledActionBarContent>\n </StyledActionBar>\n {isView && (\n <StyledContent $hasSectionBar={hasSectionBar}>{children}</StyledContent>\n )}\n {!isView && (\n <StyledContent $hasSectionBar={hasSectionBar}>\n <Textarea defaultValue={content} $fullWidth />\n </StyledContent>\n )}\n </>\n );\n}\n\nexport { ActionBar };\n";
@@ -3,7 +3,11 @@ import { useContext, useState } from "react";
3
3
  import styled, { css } from "styled-components";
4
4
  import { Icon } from "@/components/layout/Icon";
5
5
  import { mq, Theme } from "@/app/theme";
6
- import { resetButton, Textarea } from "cherry-styled-components";
6
+ import {
7
+ interactiveStyles,
8
+ resetButton,
9
+ Textarea,
10
+ } from "cherry-styled-components";
7
11
  import { SectionBarContext } from "@/components/layout/DocsComponents";
8
12
  import { StyledSmallButton } from "@/components/layout/SharedStyled";
9
13
 
@@ -51,23 +55,32 @@ const StyledCopyButton = styled(StyledSmallButton)<{
51
55
  }
52
56
  \`;
53
57
 
58
+ // Mirrors the geometry and interaction of Cherry's ThemeToggle so the two
59
+ // pills look identical side by side: interactiveStyles supplies the border
60
+ // highlight + focus/active rings (no scale effect), and the space-between
61
+ // layout with 6px padding puts each 16px icon's center exactly where the
62
+ // 24px knob's center sits in its resting (left: 2px) and active
63
+ // (translateX(26px)) positions.
54
64
  const StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>\`
55
65
  \${resetButton}
66
+ \${interactiveStyles}
56
67
  width: 56px;
57
- height: 32px;
68
+ height: 30px;
58
69
  border-radius: 30px;
59
70
  display: flex;
71
+ align-items: center;
72
+ justify-content: space-between;
73
+ padding: 0 6px;
60
74
  position: relative;
61
75
  margin: auto 0;
62
- transform: scale(1);
63
76
  background: \${({ theme }) => theme.colors.light};
64
- border: solid 1px \${({ theme }) => theme.colors.grayLight};
77
+ border-color: \${({ theme }) => theme.colors.grayLight};
65
78
 
66
79
  &::after {
67
80
  content: "";
68
81
  position: absolute;
69
- top: 3px;
70
- left: 3px;
82
+ top: 2px;
83
+ left: 2px;
71
84
  width: 24px;
72
85
  height: 24px;
73
86
  border-radius: 50%;
@@ -78,7 +91,7 @@ const StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>\`
78
91
  \${({ $isActive }) =>
79
92
  !$isActive &&
80
93
  css\`
81
- transform: translateX(24px);
94
+ transform: translateX(26px);
82
95
  \`}
83
96
  }
84
97
 
@@ -86,35 +99,17 @@ const StyledToggle = styled.button<{ theme: Theme; $isActive?: boolean }>\`
86
99
  width: 16px;
87
100
  height: 16px;
88
101
  object-fit: contain;
89
- margin: auto;
90
102
  transition: all 0.3s ease;
91
103
  position: relative;
92
104
  z-index: 2;
93
105
  }
94
106
 
95
- & .lucide-eye {
96
- transform: translateX(1px);
97
- }
98
-
99
- & .lucide-code-xml {
100
- transform: translateX(-1px);
101
- }
102
-
103
107
  & svg[stroke] {
104
108
  stroke: \${({ theme }) => theme.colors.primary};
105
109
  }
106
110
 
107
- &:hover {
108
- transform: scale(1.05);
109
- color: \${({ theme }) => theme.colors.accent};
110
-
111
- & svg[stroke] {
112
- stroke: \${({ theme }) => theme.colors.accent};
113
- }
114
- }
115
-
116
- &:active {
117
- transform: scale(0.97);
111
+ &:hover svg[stroke] {
112
+ stroke: \${({ theme }) => theme.colors.accent};
118
113
  }
119
114
  \`;
120
115
 
@@ -1 +1 @@
1
- export declare const cherryThemeProviderTemplate = "import React from \"react\";\nimport { Theme } from \"@/app/theme\";\nimport { ClientThemeProvider } from \"@/components/layout/ClientThemeProvider\";\n\n// Light vs dark is decided by the \"dark\" class on <html>, set before paint\n// by the theme-init blocking script in the root layout. The theme object\n// itself references CSS variables that flip per :root vs :root.dark, so\n// no per-request server data is needed and pages stay statically renderable.\nfunction CherryThemeProvider({\n children,\n theme,\n}: {\n children: React.ReactNode;\n theme: Theme;\n}) {\n return <ClientThemeProvider theme={theme}>{children}</ClientThemeProvider>;\n}\n\nexport { CherryThemeProvider };\n";
1
+ export declare const cherryThemeProviderTemplate = "import React from \"react\";\nimport { ClientThemeProvider } from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { GlobalStyles } from \"@/components/layout/GlobalStyles\";\n\n/**\n * Wraps Cherry's ClientThemeProvider, which swaps theme/themeDark on toggle,\n * persists the choice to the `theme` cookie + localStorage, and keeps the\n * \"dark\" class on <html> in sync. Deliberately reads NO request data\n * (cookies/headers) so pages stay statically renderable \u2014 the blocking\n * theme-init script in the root layout sets the \"dark\" class before paint\n * and hides the body on dark visits; the provider reconciles against the\n * cookie on mount and removes the hiding style. $globalStyles is off because\n * this app ships its own GlobalStyles; $themeColor tracks \"light\", the\n * html/body background.\n */\nfunction CherryThemeProvider({\n children,\n theme,\n themeDark,\n}: {\n children: React.ReactNode;\n theme: Theme;\n themeDark: Theme;\n}) {\n return (\n <ClientThemeProvider\n theme={theme}\n themeDark={themeDark}\n $globalStyles={false}\n $themeColor=\"light\"\n >\n <GlobalStyles />\n {children}\n </ClientThemeProvider>\n );\n}\n\nexport { CherryThemeProvider };\n";
@@ -1,19 +1,39 @@
1
1
  export const cherryThemeProviderTemplate = `import React from "react";
2
+ import { ClientThemeProvider } from "cherry-styled-components";
2
3
  import { Theme } from "@/app/theme";
3
- import { ClientThemeProvider } from "@/components/layout/ClientThemeProvider";
4
+ import { GlobalStyles } from "@/components/layout/GlobalStyles";
4
5
 
5
- // Light vs dark is decided by the "dark" class on <html>, set before paint
6
- // by the theme-init blocking script in the root layout. The theme object
7
- // itself references CSS variables that flip per :root vs :root.dark, so
8
- // no per-request server data is needed and pages stay statically renderable.
6
+ /**
7
+ * Wraps Cherry's ClientThemeProvider, which swaps theme/themeDark on toggle,
8
+ * persists the choice to the \`theme\` cookie + localStorage, and keeps the
9
+ * "dark" class on <html> in sync. Deliberately reads NO request data
10
+ * (cookies/headers) so pages stay statically renderable — the blocking
11
+ * theme-init script in the root layout sets the "dark" class before paint
12
+ * and hides the body on dark visits; the provider reconciles against the
13
+ * cookie on mount and removes the hiding style. $globalStyles is off because
14
+ * this app ships its own GlobalStyles; $themeColor tracks "light", the
15
+ * html/body background.
16
+ */
9
17
  function CherryThemeProvider({
10
18
  children,
11
19
  theme,
20
+ themeDark,
12
21
  }: {
13
22
  children: React.ReactNode;
14
23
  theme: Theme;
24
+ themeDark: Theme;
15
25
  }) {
16
- return <ClientThemeProvider theme={theme}>{children}</ClientThemeProvider>;
26
+ return (
27
+ <ClientThemeProvider
28
+ theme={theme}
29
+ themeDark={themeDark}
30
+ $globalStyles={false}
31
+ $themeColor="light"
32
+ >
33
+ <GlobalStyles />
34
+ {children}
35
+ </ClientThemeProvider>
36
+ );
17
37
  }
18
38
 
19
39
  export { CherryThemeProvider };
@@ -1 +1 @@
1
- export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { styledCode } 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\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\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 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\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n background: ${({ $copied }) =>\n $copied ? \"rgba(45, 164, 78, 0.1)\" : \"transparent\"};\n border: solid 1px\n ${({ $copied }) => ($copied ? \"#2da44e\" : \"rgba(0, 0, 0, 0.1)\")};\n color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"#57606a\")};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px 8px;\n font-size: 12px;\n font-family: ${({ theme }) => theme.fonts.mono};\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"#57606a\")};\n }\n\n &:hover {\n background: ${({ $copied }) =>\n $copied ? \"rgba(45, 164, 78, 0.2)\" : \"rgba(0, 0, 0, 0.05)\"};\n border-color: ${({ $copied }) =>\n $copied ? \"#2da44e\" : \"rgba(0, 0, 0, 0.2)\"};\n }\n\n &:active {\n transform: scale(0.95);\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 color: ${({ $copied }) => ($copied ? \"#7ee787\" : \"#c9d1d9\")};\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#7ee787\" : \"#c9d1d9\")};\n }\n\n &:hover {\n background: ${({ $copied }) =>\n $copied ? \"rgba(126, 231, 135, 0.3)\" : \"rgba(255, 255, 255, 0.1)\"};\n border-color: ${({ $copied }) =>\n $copied ? \"#7ee787\" : \"rgba(255, 255, 255, 0.2)\"};\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 }\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 ${({ theme }) => styledCode(theme)};\n ${lightSyntaxHighlight};\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({ code, language = \"javascript\", theme, className }: 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 <CopyButton onClick={handleCopy} $copied={copied} theme={theme}>\n {copied ? (\n <>\n <Icon name=\"check\" size={12} />\n <span>Copied!</span>\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={12} />\n <span>Copy</span>\n </>\n )}\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code };\n";
1
+ export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo } 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 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 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/* 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 }\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 :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({ code, language = \"javascript\", theme, className }: 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 <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\nexport { Code };\n";
@@ -1,13 +1,18 @@
1
1
  export const codeTemplate = `"use client";
2
2
  import { useState, useCallback, useMemo } from "react";
3
3
  import styled, { css } from "styled-components";
4
- import { styledCode } from "cherry-styled-components";
4
+ import {
5
+ interactiveStyles,
6
+ resetButton,
7
+ styledCode,
8
+ } from "cherry-styled-components";
5
9
  import { Theme } from "@/app/theme";
6
10
  import { unified } from "unified";
7
11
  import rehypeParse from "rehype-parse";
8
12
  import rehypeHighlight from "rehype-highlight";
9
13
  import rehypeStringify from "rehype-stringify";
10
14
  import { Icon } from "@/components/layout/Icon";
15
+ import { thinScrollbar } from "@/components/layout/SharedStyled";
11
16
 
12
17
  interface CodeProps extends Omit<
13
18
  React.HTMLAttributes<HTMLDivElement>,
@@ -70,54 +75,40 @@ const Dot = styled.span<{ theme: Theme }>\`
70
75
  }
71
76
  \`;
72
77
 
78
+ /* Icon-only copy button. interactiveStyles supplies the border highlight on
79
+ hover plus the focus/active rings (no scale effect); the GitHub-style
80
+ copied/base colors stay fixed like the rest of the code block. The dark
81
+ block re-declares the hover border because its higher-specificity
82
+ :root.dark & border-color would otherwise override the mixin's hover. */
73
83
  const CopyButton = styled.button<{ theme: Theme; $copied: boolean }>\`
84
+ \${resetButton}
85
+ \${interactiveStyles}
74
86
  background: \${({ $copied }) =>
75
87
  $copied ? "rgba(45, 164, 78, 0.1)" : "transparent"};
76
- border: solid 1px
77
- \${({ $copied }) => ($copied ? "#2da44e" : "rgba(0, 0, 0, 0.1)")};
78
- color: \${({ $copied }) => ($copied ? "#2da44e" : "#57606a")};
88
+ border-color: \${({ $copied }) => ($copied ? "#2da44e" : "rgba(0, 0, 0, 0.1)")};
79
89
  border-radius: \${({ theme }) => theme.spacing.radius.xs};
80
- padding: 4px 8px;
81
- font-size: 12px;
82
- font-family: \${({ theme }) => theme.fonts.mono};
83
- cursor: pointer;
84
- transition: all 0.2s ease;
90
+ padding: 4px;
85
91
  display: flex;
86
92
  align-items: center;
87
- gap: 4px;
93
+ justify-content: center;
88
94
  margin-right: -6px;
89
95
 
90
96
  & svg.lucide {
91
97
  color: \${({ $copied }) => ($copied ? "#2da44e" : "#57606a")};
92
98
  }
93
99
 
94
- &:hover {
95
- background: \${({ $copied }) =>
96
- $copied ? "rgba(45, 164, 78, 0.2)" : "rgba(0, 0, 0, 0.05)"};
97
- border-color: \${({ $copied }) =>
98
- $copied ? "#2da44e" : "rgba(0, 0, 0, 0.2)"};
99
- }
100
-
101
- &:active {
102
- transform: scale(0.95);
103
- }
104
-
105
100
  :root.dark & {
106
101
  background: \${({ $copied }) =>
107
102
  $copied ? "rgba(126, 231, 135, 0.2)" : "transparent"};
108
103
  border-color: \${({ $copied }) =>
109
104
  $copied ? "#7ee787" : "rgba(255, 255, 255, 0.1)"};
110
- color: \${({ $copied }) => ($copied ? "#7ee787" : "#c9d1d9")};
111
105
 
112
106
  & svg.lucide {
113
107
  color: \${({ $copied }) => ($copied ? "#7ee787" : "#c9d1d9")};
114
108
  }
115
109
 
116
110
  &:hover {
117
- background: \${({ $copied }) =>
118
- $copied ? "rgba(126, 231, 135, 0.3)" : "rgba(255, 255, 255, 0.1)"};
119
- border-color: \${({ $copied }) =>
120
- $copied ? "#7ee787" : "rgba(255, 255, 255, 0.2)"};
111
+ border-color: \${({ theme }) => theme.colors.primary};
121
112
  }
122
113
  }
123
114
  \`;
@@ -296,6 +287,7 @@ const Body = styled.div<{ theme: Theme }>\`
296
287
  overflow-x: auto;
297
288
  overflow-y: auto;
298
289
  max-height: calc(100dvh - 400px);
290
+ \${thinScrollbar};
299
291
  \${({ theme }) => styledCode(theme)};
300
292
  \${lightSyntaxHighlight};
301
293
 
@@ -363,18 +355,13 @@ function Code({ code, language = "javascript", theme, className }: CodeProps) {
363
355
  <Dot theme={theme} />
364
356
  <Dot theme={theme} />
365
357
  </DotsContainer>
366
- <CopyButton onClick={handleCopy} $copied={copied} theme={theme}>
367
- {copied ? (
368
- <>
369
- <Icon name="check" size={12} />
370
- <span>Copied!</span>
371
- </>
372
- ) : (
373
- <>
374
- <Icon name="copy" size={12} />
375
- <span>Copy</span>
376
- </>
377
- )}
358
+ <CopyButton
359
+ onClick={handleCopy}
360
+ $copied={copied}
361
+ theme={theme}
362
+ aria-label={copied ? "Copied" : "Copy code"}
363
+ >
364
+ <Icon name={copied ? "check" : "copy"} size={12} />
378
365
  </CopyButton>
379
366
  </TopBar>
380
367
  <Body
@@ -1 +1 @@
1
- export declare const demoThemeTemplate = "\"use client\";\nimport { Button } from \"@/components/layout/Button\";\nimport { useThemeOverride } from \"@/components/layout/ClientThemeProvider\";\nimport { Columns } from \"@/components/layout/Columns\";\n\ninterface DemoThemeProps {\n variant: \"purple\" | \"green\" | \"yellow\";\n}\n\ntype Palette = Record<string, string>;\n\n// Each preset is light + dark color overrides applied directly to CSS custom\n// properties on <html>. This mirrors what a user editing theme.json would see:\n// the variable names are identical, the variables flip light vs dark via the\n// \"dark\" class on <html>, and Reset clears the overrides so the defaults take\n// over again.\nconst PRESETS: Record<\n DemoThemeProps[\"variant\"],\n { light: Palette; dark: Palette; label: string }\n> = {\n purple: {\n label: \"Purple\",\n light: {\n primaryLight: \"#c4b5fd\",\n primary: \"#8b5cf6\",\n primaryDark: \"#5b21b6\",\n },\n dark: {\n primaryLight: \"#ddd6fe\",\n primary: \"#a78bfa\",\n primaryDark: \"#7c3aed\",\n },\n },\n green: {\n label: \"Green\",\n light: {\n primaryLight: \"#86efac\",\n primary: \"#22c55e\",\n primaryDark: \"#15803d\",\n },\n dark: {\n primaryLight: \"#6ee7b7\",\n primary: \"#10b981\",\n primaryDark: \"#065f46\",\n },\n },\n yellow: {\n label: \"Yellow\",\n light: {\n primaryLight: \"#fbbf24\",\n primary: \"#f59e0b\",\n primaryDark: \"#d97706\",\n },\n dark: {\n primaryLight: \"#fed7aa\",\n primary: \"#fb923c\",\n primaryDark: \"#ea580c\",\n },\n },\n};\n\nconst PRESET_KEYS = [\"primaryLight\", \"primary\", \"primaryDark\"] as const;\n\nfunction applyPreset(palette: Palette) {\n const root = document.documentElement;\n for (const key of PRESET_KEYS) {\n if (palette[key]) root.style.setProperty(`--color-${key}`, palette[key]);\n }\n}\n\nfunction clearPreset() {\n const root = document.documentElement;\n for (const key of PRESET_KEYS) {\n root.style.removeProperty(`--color-${key}`);\n }\n}\n\nfunction DemoTheme({ variant }: DemoThemeProps) {\n const { setMode } = useThemeOverride();\n const preset = PRESETS[variant];\n\n if (!preset) {\n return (\n <Columns cols={2}>\n <Button onClick={() => clearPreset()} fullWidth>\n Reset to Default\n </Button>\n </Columns>\n );\n }\n\n return (\n <Columns cols={2}>\n <Button\n onClick={() => {\n applyPreset(preset.light);\n setMode(\"light\");\n }}\n icon=\"sun\"\n fullWidth\n >\n Demo {preset.label} Light\n </Button>\n <Button\n outline\n onClick={() => {\n applyPreset(preset.dark);\n setMode(\"dark\");\n }}\n icon=\"moon-star\"\n fullWidth\n >\n Demo {preset.label} Dark\n </Button>\n </Columns>\n );\n}\n\nexport { DemoTheme };\n";
1
+ export declare const demoThemeTemplate = "\"use client\";\nimport { useContext } from \"react\";\nimport { useTheme } from \"styled-components\";\nimport { ThemeContext } from \"cherry-styled-components\";\nimport {\n buildColors,\n colorsLight,\n colorsDark,\n theme,\n themeDark,\n Theme,\n} from \"@/app/theme\";\nimport { Button } from \"@/components/layout/Button\";\nimport { Columns } from \"@/components/layout/Columns\";\n\ninterface DemoThemeProps {\n variant: \"purple\" | \"green\" | \"yellow\";\n}\n\ntype Palette = Record<string, string>;\n\n// Each preset is light + dark color overrides, mirroring what a user editing\n// theme.json would see. Applying one does two things: swaps in a theme object\n// rebuilt with the overridden palette (Cherry's setTheme, which also persists\n// the mode), and mirrors the overrides onto the --color-* CSS custom\n// properties on <html> for the plain-CSS consumers that read variables\n// (e.g. Callout's and Code's :root.dark blocks). Reset clears both so the\n// defaults take over again.\nconst PRESETS: Record<\n DemoThemeProps[\"variant\"],\n { light: Palette; dark: Palette; label: string }\n> = {\n purple: {\n label: \"Purple\",\n light: {\n primaryLight: \"#c4b5fd\",\n primary: \"#8b5cf6\",\n primaryDark: \"#5b21b6\",\n },\n dark: {\n primaryLight: \"#ddd6fe\",\n primary: \"#a78bfa\",\n primaryDark: \"#7c3aed\",\n },\n },\n green: {\n label: \"Green\",\n light: {\n primaryLight: \"#86efac\",\n primary: \"#22c55e\",\n primaryDark: \"#15803d\",\n },\n dark: {\n primaryLight: \"#6ee7b7\",\n primary: \"#10b981\",\n primaryDark: \"#065f46\",\n },\n },\n yellow: {\n label: \"Yellow\",\n light: {\n primaryLight: \"#fbbf24\",\n primary: \"#f59e0b\",\n primaryDark: \"#d97706\",\n },\n dark: {\n primaryLight: \"#fed7aa\",\n primary: \"#fb923c\",\n primaryDark: \"#ea580c\",\n },\n },\n};\n\nconst PRESET_KEYS = [\"primaryLight\", \"primary\", \"primaryDark\"] as const;\n\nfunction applyPresetVars(palette: Palette) {\n const root = document.documentElement;\n for (const key of PRESET_KEYS) {\n if (palette[key]) root.style.setProperty(`--color-${key}`, palette[key]);\n }\n}\n\nfunction clearPresetVars() {\n const root = document.documentElement;\n for (const key of PRESET_KEYS) {\n root.style.removeProperty(`--color-${key}`);\n }\n}\n\n// Rebuilds a mode's theme with the preset palette merged in, so the semantic\n// tokens (accent, accentStrong, \u2026) derive from the preset colors exactly like\n// they would from a theme.json override.\nfunction presetTheme(palette: Palette, dark: boolean): Theme {\n const base = dark ? themeDark : theme;\n const paletteBase = dark ? colorsDark : colorsLight;\n return {\n ...base,\n colors: buildColors({ ...paletteBase, ...palette }, dark),\n };\n}\n\nfunction DemoTheme({ variant }: DemoThemeProps) {\n const { setTheme } = useContext(ThemeContext);\n const activeTheme = useTheme() as Theme;\n const preset = PRESETS[variant];\n\n if (!preset) {\n return (\n <Columns cols={2}>\n <Button\n onClick={() => {\n clearPresetVars();\n setTheme(activeTheme.isDark ? themeDark : theme);\n }}\n fullWidth\n >\n Reset to Default\n </Button>\n </Columns>\n );\n }\n\n return (\n <Columns cols={2}>\n <Button\n onClick={() => {\n applyPresetVars(preset.light);\n setTheme(presetTheme(preset.light, false));\n }}\n icon=\"sun\"\n fullWidth\n >\n Demo {preset.label} Light\n </Button>\n <Button\n outline\n onClick={() => {\n applyPresetVars(preset.dark);\n setTheme(presetTheme(preset.dark, true));\n }}\n icon=\"moon-star\"\n fullWidth\n >\n Demo {preset.label} Dark\n </Button>\n </Columns>\n );\n}\n\nexport { DemoTheme };\n";