doccupine 0.0.95 → 0.0.96

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.
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import path from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import matter from "gray-matter";
8
8
  import chalk from "chalk";
9
- import { appStructure, startingDocsStructure } from "./lib/structures.js";
9
+ import { appStructure, obsoleteFiles, startingDocsStructure, } from "./lib/structures.js";
10
10
  import { rootLayoutTemplate, siteLayoutTemplate } from "./lib/layout.js";
11
11
  import { ConfigManager } from "./lib/config-manager.js";
12
12
  import { findAvailablePort, generateSlug, getFullSlug, escapeTemplateContent, } from "./lib/utils.js";
@@ -87,6 +87,9 @@ class MDXToNextJSGenerator {
87
87
  // generateSectionIndexPages, so nothing here is user-authored. Config JSONs
88
88
  // and other generated dirs live outside app/ and are untouched.
89
89
  await fs.remove(path.join(this.outputDir, "app"));
90
+ // Drop files that earlier CLI versions generated but no longer exist in
91
+ // the template set, so upgraded projects don't keep stale copies.
92
+ await Promise.all(obsoleteFiles.map((file) => fs.remove(path.join(this.outputDir, file))));
90
93
  const siteUrl = await this.loadSiteUrl();
91
94
  const structure = {
92
95
  ...appStructure,
@@ -62,9 +62,24 @@ function fontDeclLine(fontConfig) {
62
62
  });`
63
63
  : 'const font = Inter({ subsets: ["latin"] });';
64
64
  }
65
- // The inline blocking script that resolves dark mode before first paint by
66
- // adding the "dark" class to <html>. Shared by the root layout's <head>.
67
- const THEME_INIT_SCRIPT = `(function(){try{var c=document.cookie.split(";").map(function(s){return s.trim();}).find(function(s){return s.indexOf("theme=")===0;});var v=c?c.split("=")[1]:null;var d=v?v==="dark":(window.matchMedia&&window.matchMedia("(prefers-color-scheme:dark)").matches);if(!v){document.cookie="theme="+(d?"dark":"light")+";path=/;max-age=31536000;SameSite=Lax";}if(d){document.documentElement.classList.add("dark");document.documentElement.style.colorScheme="dark";}else{document.documentElement.style.colorScheme="light";}}catch(e){}})();`;
65
+ // The inline blocking script that resolves dark mode before first paint.
66
+ // Adds the "dark" class to <html> (so the CSS variables emitted by
67
+ // GlobalStyles paint dark immediately) and, on dark visits, injects a
68
+ // "#__theme-init" style that hides the body AND forces a dark html
69
+ // background: the server always renders with the light theme (pages are
70
+ // force-static, so no cookie read), and Cherry's ClientThemeProvider
71
+ // briefly re-syncs the "dark" class off the initial light theme during
72
+ // mount, so without the forced background the html would flash white for a
73
+ // frame. A second "__theme-transitions" style disables all transitions so
74
+ // the light-to-dark swap on mount snaps instantly instead of animating every
75
+ // element that declares a color transition. It can't live in "__theme-init":
76
+ // the provider removes that in the same frame the dark colors commit, so the
77
+ // browser would compute one light-to-dark style diff with transitions
78
+ // re-enabled and animate it anyway. Instead a MutationObserver waits for the
79
+ // provider to remove "__theme-init" (themeDark committed, body unhidden),
80
+ // forces a reflow, and drops the transition suppression two painted frames
81
+ // later — restoring transitions for user-initiated toggles.
82
+ const THEME_INIT_SCRIPT = `(function(){try{var c=document.cookie.split(";").map(function(s){return s.trim();}).find(function(s){return s.indexOf("theme=")===0;});var v=c?c.split("=")[1]:null;var d=v?v==="dark":(window.matchMedia&&window.matchMedia("(prefers-color-scheme:dark)").matches);if(!v){document.cookie="theme="+(d?"dark":"light")+";path=/;max-age=31536000;SameSite=Lax";}if(d){document.documentElement.classList.add("dark");document.documentElement.style.colorScheme="dark";var s=document.createElement("style");s.id="__theme-init";s.textContent="html{background:#000!important;color-scheme:dark}body{visibility:hidden}";document.head.appendChild(s);var t=document.createElement("style");t.id="__theme-transitions";t.textContent="*,*::before,*::after{transition:none!important}";document.head.appendChild(t);var f=function(){var e=document.getElementById("__theme-transitions");if(!e)return;void document.body.offsetHeight;requestAnimationFrame(function(){requestAnimationFrame(function(){e.remove();});});};var o=new MutationObserver(function(){if(!document.getElementById("__theme-init")){o.disconnect();f();}});o.observe(document.head,{childList:true});setTimeout(f,10000);}else{document.documentElement.style.colorScheme="light";}}catch(e){}})();`;
68
83
  /**
69
84
  * Root layout ("app/layout.tsx"). Minimal shell: html/body, fonts, the theme
70
85
  * provider stack, and (optionally) PostHog. It renders `children` directly so
@@ -78,7 +93,7 @@ export const rootLayoutTemplate = (fontConfig, analyticsEnabled = false) => {
78
93
  return `import type { Metadata } from "next";
79
94
  ${fontImportLine(fontConfig)}
80
95
  import { StyledComponentsRegistry } from "cherry-styled-components";
81
- import { theme } from "@/app/theme";
96
+ import { theme, themeDark } from "@/app/theme";
82
97
  import { CherryThemeProvider } from "@/components/layout/CherryThemeProvider";
83
98
  import { config } from "@/utils/config";
84
99
  ${analyticsEnabled ? `import { PostHogProvider } from "@/components/PostHogProvider";\n` : ""}
@@ -118,10 +133,11 @@ export default function RootLayout({
118
133
  return (
119
134
  <html lang="en" suppressHydrationWarning>
120
135
  <head>
121
- {/* Resolves dark mode before first paint by adding the "dark" class
122
- to <html> when needed. CSS variables in GlobalStyles flip values
123
- on :root vs :root.dark, so the right palette renders without a
124
- React roundtrip. Inlined as a plain <script> (not next/script) so
136
+ {/* Resolves dark mode before first paint: adds the "dark" class to
137
+ <html> (flipping the CSS variables in GlobalStyles) and hides the
138
+ body until Cherry's ClientThemeProvider has swapped in themeDark
139
+ on mount the server always renders the light theme since pages
140
+ are force-static. Inlined as a plain <script> (not next/script) so
125
141
  it ships in the SSR HTML and runs synchronously before paint —
126
142
  next/script with beforeInteractive is async in App Router and
127
143
  would still show a flash. suppressHydrationWarning on <html>
@@ -137,9 +153,13 @@ export default function RootLayout({
137
153
  <StyledComponentsRegistry>
138
154
  ${analyticsEnabled
139
155
  ? ` <PostHogProvider>
140
- <CherryThemeProvider theme={theme}>{children}</CherryThemeProvider>
156
+ <CherryThemeProvider theme={theme} themeDark={themeDark}>
157
+ {children}
158
+ </CherryThemeProvider>
141
159
  </PostHogProvider>`
142
- : ` <CherryThemeProvider theme={theme}>{children}</CherryThemeProvider>`}
160
+ : ` <CherryThemeProvider theme={theme} themeDark={themeDark}>
161
+ {children}
162
+ </CherryThemeProvider>`}
143
163
  </StyledComponentsRegistry>
144
164
  </body>
145
165
  </html>
@@ -1,2 +1,3 @@
1
1
  export declare const appStructure: Record<string, string>;
2
+ export declare const obsoleteFiles: string[];
2
3
  export declare const startingDocsStructure: Record<string, string>;
@@ -13,7 +13,6 @@ import { searchRoutesTemplate } from "../templates/app/api/search/route.js";
13
13
  import { notFoundTemplate } from "../templates/app/not-found.js";
14
14
  import { themeTemplate } from "../templates/app/theme.js";
15
15
  import { chatTemplate } from "../templates/components/Chat.js";
16
- import { clickOutsideTemplate } from "../templates/components/ClickOutside.js";
17
16
  import { lockBodyScrollTemplate } from "../templates/components/LockBodyScroll.js";
18
17
  import { docsTemplate } from "../templates/components/Docs.js";
19
18
  import { docsSideBarTemplate } from "../templates/components/DocsSideBar.js";
@@ -32,7 +31,6 @@ import { buttonTemplate } from "../templates/components/layout/Button.js";
32
31
  import { calloutTemplate } from "../templates/components/layout/Callout.js";
33
32
  import { cardTemplate } from "../templates/components/layout/Card.js";
34
33
  import { cherryThemeProviderTemplate } from "../templates/components/layout/CherryThemeProvider.js";
35
- import { clientThemeProviderTemplate } from "../templates/components/layout/ClientThemeProvider.js";
36
34
  import { colorSwatchTemplate } from "../templates/components/layout/ColorSwatch.js";
37
35
  import { codeTemplate } from "../templates/components/layout/Code.js";
38
36
  import { columnsTemplate } from "../templates/components/layout/Columns.js";
@@ -50,7 +48,6 @@ import { siteGateComponentTemplate } from "../templates/components/layout/SiteGa
50
48
  import { staticLinksTemplate } from "../templates/components/layout/StaticLinks.js";
51
49
  import { stepsTemplate } from "../templates/components/layout/Steps.js";
52
50
  import { tabsTemplate } from "../templates/components/layout/Tabs.js";
53
- import { themeToggleTemplate } from "../templates/components/layout/ThemeToggle.js";
54
51
  import { typographyTemplate } from "../templates/components/layout/Typography.js";
55
52
  import { updateTemplate } from "../templates/components/layout/Update.js";
56
53
  import { searchServiceTemplate } from "../templates/services/search.js";
@@ -147,7 +144,6 @@ export const appStructure = {
147
144
  "utils/rateLimit.ts": rateLimitTemplate,
148
145
  "utils/config.ts": configTemplate,
149
146
  "components/Chat.tsx": chatTemplate,
150
- "components/ClickOutside.ts": clickOutsideTemplate,
151
147
  "components/LockBodyScroll.ts": lockBodyScrollTemplate,
152
148
  "components/Docs.tsx": docsTemplate,
153
149
  "components/DocsSideBar.tsx": docsSideBarTemplate,
@@ -165,7 +161,6 @@ export const appStructure = {
165
161
  "components/layout/Callout.tsx": calloutTemplate,
166
162
  "components/layout/Card.tsx": cardTemplate,
167
163
  "components/layout/CherryThemeProvider.tsx": cherryThemeProviderTemplate,
168
- "components/layout/ClientThemeProvider.tsx": clientThemeProviderTemplate,
169
164
  "components/layout/ColorSwatch.tsx": colorSwatchTemplate,
170
165
  "components/layout/Code.tsx": codeTemplate,
171
166
  "components/layout/Columns.tsx": columnsTemplate,
@@ -184,10 +179,18 @@ export const appStructure = {
184
179
  "components/layout/StaticLinks.tsx": staticLinksTemplate,
185
180
  "components/layout/Steps.tsx": stepsTemplate,
186
181
  "components/layout/Tabs.tsx": tabsTemplate,
187
- "components/layout/ThemeToggle.tsx": themeToggleTemplate,
188
182
  "components/layout/Typography.ts": typographyTemplate,
189
183
  "components/layout/Update.tsx": updateTemplate,
190
184
  };
185
+ // Files generated by earlier CLI versions that no longer exist in the
186
+ // template set. Unlike app/ (wiped on every run), components/ and friends
187
+ // are only overwritten, so upgraded projects would otherwise keep stale
188
+ // copies around — failing lint or importing modules that are gone.
189
+ export const obsoleteFiles = [
190
+ "components/ClickOutside.ts",
191
+ "components/layout/ClientThemeProvider.tsx",
192
+ "components/layout/ThemeToggle.tsx",
193
+ ];
191
194
  export const startingDocsStructure = {
192
195
  "accordion.mdx": accordionMdxTemplate,
193
196
  "ai-assistant.mdx": aiAssistantMdxTemplate,
@@ -1,3 +1,3 @@
1
1
  export declare const SIDEBAR_WIDTH = 280;
2
2
  export declare const CHAT_WIDTH = 420;
3
- export declare const themeTemplate = "\"use client\";\nimport customThemeJson from \"@/theme.json\";\n\n// Users can only override the brand palette via theme.json \u2014 semantic tokens\n// (accent, surface, etc.) are derived in GlobalStyles from the brand colors\n// and are not directly overridable.\ntype CustomColors = Omit<\n Colors,\n \"accent\" | \"accentStrong\" | \"accentMuted\" | \"surface\"\n>;\n\ninterface CustomTheme {\n default?: Partial<CustomColors>;\n dark?: Partial<CustomColors>;\n}\n\nconst customTheme = customThemeJson as CustomTheme;\n\nconst breakpoints: Breakpoints = {\n xs: 0,\n sm: 576,\n md: 768,\n lg: 992,\n xl: 1200,\n xxl: 1440,\n xxxl: 1920,\n};\n\nexport function mq(minWidth: keyof Breakpoints) {\n return `@media screen and (min-width: ${breakpoints[minWidth]}px)`;\n}\n\nconst spacing: Spacing = {\n maxWidth: { xs: \"1280px\", xxxl: \"1440px\" },\n padding: { xs: \"20px\", lg: \"40px\" },\n radius: { xs: \"6px\", lg: \"12px\", xl: \"30px\" },\n gridGap: { xs: \"20px\", lg: \"40px\" },\n};\n\n// Resolved hex palettes for each mode. GlobalStyles emits these as CSS\n// custom properties on :root and :root.dark. Components never read these\n// directly \u2014 they read `theme.colors.*` which resolves to var(--color-\u2026)\n// and lets the browser pick the right value based on the active class.\nexport const colorsLight: CustomColors = {\n primaryLight: \"#d1d5db\",\n primary: \"#556272\",\n primaryDark: \"#374151\",\n secondaryLight: \"#c4b5fd\",\n secondary: \"#8b5cf6\",\n secondaryDark: \"#5b21b6\",\n tertiaryLight: \"#86efac\",\n tertiary: \"#22c55e\",\n tertiaryDark: \"#15803d\",\n grayLight: \"#e5e7eb\",\n gray: \"#9ca3af\",\n grayDark: \"#4b5563\",\n success: \"#84cc16\",\n error: \"#ef4444\",\n warning: \"#eab308\",\n info: \"#06b6d4\",\n dark: \"#000000\",\n light: \"#ffffff\",\n ...(customTheme.default\n ? (customTheme.default as Partial<CustomColors>)\n : {}),\n};\n\nexport const colorsDark: CustomColors = {\n primaryLight: \"#9ca3af\",\n primary: \"#6b7280\",\n primaryDark: \"#374151\",\n secondaryLight: \"#ddd6fe\",\n secondary: \"#a78bfa\",\n secondaryDark: \"#7c3aed\",\n tertiaryLight: \"#6ee7b7\",\n tertiary: \"#10b981\",\n tertiaryDark: \"#065f46\",\n grayLight: \"#1a1a1a\",\n gray: \"#454444\",\n grayDark: \"#808080\",\n success: \"#84cc16\",\n error: \"#ef4444\",\n warning: \"#eab308\",\n info: \"#06b6d4\",\n dark: \"#ffffff\",\n light: \"#000000\",\n ...(customTheme.dark ? (customTheme.dark as Partial<CustomColors>) : {}),\n};\n\nexport const shadowsLight: Shadows = {\n xs: \"0px 4px 4px 0px rgba(18, 18, 18, 0.04), 0px 1px 3px 0px rgba(39, 41, 45, 0.02)\",\n sm: \"0px 4px 4px 0px rgba(18, 18, 18, 0.08), 0px 1px 3px 0px rgba(39, 41, 45, 0.04)\",\n md: \"0px 8px 8px 0px rgba(18, 18, 18, 0.16), 0px 2px 3px 0px rgba(39, 41, 45, 0.06)\",\n lg: \"0px 16px 24px 0px rgba(18, 18, 18, 0.20), 0px 2px 3px 0px rgba(39, 41, 45, 0.08)\",\n xl: \"0px 24px 32px 0px rgba(18, 18, 18, 0.24), 0px 2px 3px 0px rgba(39, 41, 45, 0.12)\",\n};\n\nexport const shadowsDark: Shadows = {\n xs: \"0px 4px 4px 0px rgba(255, 255, 255, 0.04), 0px 1px 3px 0px rgba(255, 255, 255, 0.02)\",\n sm: \"0px 4px 4px 0px rgba(255, 255, 255, 0.08), 0px 1px 3px 0px rgba(255, 255, 255, 0.04)\",\n md: \"0px 8px 8px 0px rgba(255, 255, 255, 0.16), 0px 2px 3px 0px rgba(255, 255, 255, 0.06)\",\n lg: \"0px 16px 24px 0px rgba(255, 255, 255, 0.20), 0px 2px 3px 0px rgba(255, 255, 255, 0.08)\",\n xl: \"0px 24px 32px 0px rgba(255, 255, 255, 0.24), 0px 2px 3px 0px rgba(255, 255, 255, 0.12)\",\n};\n\nconst fonts: Fonts = {\n text: \"Inter\",\n head: \"Inter\",\n mono: \"Roboto Mono, monospace\",\n};\n\nconst fontSizes: FontSizes = {\n hero1: { xs: \"72px\", lg: \"128px\" },\n hero2: { xs: \"60px\", lg: \"96px\" },\n hero3: { xs: \"36px\", lg: \"72px\" },\n\n h1: { xs: \"40px\", lg: \"60px\" },\n h2: { xs: \"30px\", lg: \"36px\" },\n h3: { xs: \"28px\", lg: \"30px\" },\n h4: { xs: \"24px\", lg: \"26px\" },\n h5: { xs: \"18px\", lg: \"20px\" },\n h6: { xs: \"16px\", lg: \"18px\" },\n\n text: { xs: \"14px\", lg: \"16px\" },\n strong: { xs: \"14px\", lg: \"16px\" },\n small: { xs: \"12px\", lg: \"14px\" },\n\n blockquote: { xs: \"16px\", lg: \"18px\" },\n code: { xs: \"14px\", lg: \"16px\" },\n\n button: { xs: \"16px\", lg: \"16px\" },\n buttonBig: { xs: \"18px\", lg: \"18px\" },\n buttonSmall: { xs: \"14px\", lg: \"14px\" },\n\n input: { xs: \"16px\", lg: \"16px\" },\n inputBig: { xs: \"18px\", lg: \"18px\" },\n inputSmall: { xs: \"14px\", lg: \"14px\" },\n};\n\nconst lineHeights: LineHeights = {\n hero1: { xs: \"1.1\", lg: \"1.1\" },\n hero2: { xs: \"1.1\", lg: \"1.1\" },\n hero3: { xs: \"1.17\", lg: \"1.1\" },\n\n h1: { xs: \"1\", lg: \"1.07\" },\n h2: { xs: \"1.2\", lg: \"1.2\" },\n h3: { xs: \"1.3\", lg: \"1.5\" },\n h4: { xs: \"1.3\", lg: \"1.5\" },\n h5: { xs: \"1.6\", lg: \"1.5\" },\n h6: { xs: \"1.6\", lg: \"1.6\" },\n\n text: { xs: \"1.7\", lg: \"1.7\" },\n strong: { xs: \"1.7\", lg: \"1.7\" },\n small: { xs: \"1.7\", lg: \"1.7\" },\n\n blockquote: { xs: \"1.7\", lg: \"1.7\" },\n code: { xs: \"1.7\", lg: \"1.7\" },\n\n button: { xs: \"1\", lg: \"1\" },\n buttonBig: { xs: \"1\", lg: \"1\" },\n buttonSmall: { xs: \"1\", lg: \"1\" },\n\n input: { xs: \"1\", lg: \"1\" },\n inputBig: { xs: \"1\", lg: \"1\" },\n inputSmall: { xs: \"1\", lg: \"1\" },\n};\n\n// Single theme object exported to consumers. Every color/shadow value is a\n// CSS custom-property reference; the browser resolves it against :root or\n// :root.dark depending on the active class. Components access these exactly\n// like before \u2014 `theme.colors.primary`, `theme.shadows.sm` \u2014 but the values\n// flip without React re-rendering.\nconst colors: Colors = {\n // Brand palette \u2014 directly customizable via theme.json's \"default\" / \"dark\".\n primaryLight: \"var(--color-primaryLight)\",\n primary: \"var(--color-primary)\",\n primaryDark: \"var(--color-primaryDark)\",\n secondaryLight: \"var(--color-secondaryLight)\",\n secondary: \"var(--color-secondary)\",\n secondaryDark: \"var(--color-secondaryDark)\",\n tertiaryLight: \"var(--color-tertiaryLight)\",\n tertiary: \"var(--color-tertiary)\",\n tertiaryDark: \"var(--color-tertiaryDark)\",\n grayLight: \"var(--color-grayLight)\",\n gray: \"var(--color-gray)\",\n grayDark: \"var(--color-grayDark)\",\n success: \"var(--color-success)\",\n error: \"var(--color-error)\",\n warning: \"var(--color-warning)\",\n info: \"var(--color-info)\",\n dark: \"var(--color-dark)\",\n light: \"var(--color-light)\",\n\n // Semantic tokens \u2014 derived in GlobalStyles to capture the most common\n // mode-aware swaps that components used to express via `theme.isDark ? A : B`.\n // They follow the same single-noun convention as the brand palette.\n accent: \"var(--color-accent)\",\n accentStrong: \"var(--color-accentStrong)\",\n accentMuted: \"var(--color-accentMuted)\",\n surface: \"var(--color-surface)\",\n};\n\nconst shadows: Shadows = {\n xs: \"var(--shadow-xs)\",\n sm: \"var(--shadow-sm)\",\n md: \"var(--shadow-md)\",\n lg: \"var(--shadow-lg)\",\n xl: \"var(--shadow-xl)\",\n};\n\nexport const theme: Theme = {\n breakpoints,\n spacing,\n colors,\n shadows,\n fonts,\n fontSizes,\n lineHeights,\n // Stub for type compatibility with cherry-styled-components' Theme. Mode\n // switching lives entirely in CSS vars flipped by :root.dark, so our own\n // code never branches on this. Cherry's internal `isDark ? \u2026 : \u2026` branches\n // (e.g. buttonStyles' filled-button text) are handled where they surface\n // by re-pinning to a mode-agnostic semantic token \u2014 see components/layout/Button.tsx.\n isDark: false,\n};\n\ninterface Breakpoints<TNumber = number> {\n xs: TNumber;\n sm: TNumber;\n md: TNumber;\n lg: TNumber;\n xl: TNumber;\n xxl: TNumber;\n xxxl: TNumber;\n}\n\ninterface Spacing<TString = string> {\n maxWidth: { xs: TString; xxxl: TString };\n padding: { xs: TString; lg: TString };\n radius: { xs: TString; lg: TString; xl: TString };\n gridGap: { xs: TString; lg: TString };\n}\n\n// Matches cherry-styled-components' Colors shape for the brand keys, plus a\n// handful of semantic tokens derived from the brand palette by GlobalStyles.\n// The brand keys (primaryLight\u2026light) are customizable via theme.json; the\n// semantic keys (accent*, surface) are derived and not overridable \u2014 see\n// CustomColors above.\ninterface Colors<TString = string> {\n primaryLight: TString;\n primary: TString;\n primaryDark: TString;\n\n secondaryLight: TString;\n secondary: TString;\n secondaryDark: TString;\n\n tertiaryLight: TString;\n tertiary: TString;\n tertiaryDark: TString;\n\n grayLight: TString;\n gray: TString;\n grayDark: TString;\n\n success: TString;\n error: TString;\n warning: TString;\n info: TString;\n\n dark: TString;\n light: TString;\n\n /** High-contrast accent text. Was: isDark ? primaryLight : primaryDark. */\n accent: TString;\n /** Slightly stronger accent (lightened/darkened by ~10%). */\n accentStrong: TString;\n /** Muted body text. Was: isDark ? grayDark : primary. */\n accentMuted: TString;\n /** Elevated surface color, white-ish in both modes. Was: isDark ? dark : light. */\n surface: TString;\n}\n\ninterface Shadows<TString = string> {\n xs: TString;\n sm: TString;\n md: TString;\n lg: TString;\n xl: TString;\n}\n\ninterface Fonts<TString = string> {\n head: TString;\n text: TString;\n mono: TString;\n}\n\ninterface FontSizes<TString = string> {\n hero1: { xs: TString; lg: TString };\n hero2: { xs: TString; lg: TString };\n hero3: { xs: TString; lg: TString };\n\n h1: { xs: TString; lg: TString };\n h2: { xs: TString; lg: TString };\n h3: { xs: TString; lg: TString };\n h4: { xs: TString; lg: TString };\n h5: { xs: TString; lg: TString };\n h6: { xs: TString; lg: TString };\n\n text: { xs: TString; lg: TString };\n strong: { xs: TString; lg: TString };\n small: { xs: TString; lg: TString };\n\n blockquote: { xs: TString; lg: TString };\n code: { xs: TString; lg: TString };\n\n button: { xs: TString; lg: TString };\n buttonBig: { xs: TString; lg: TString };\n buttonSmall: { xs: TString; lg: TString };\n\n input: { xs: TString; lg: TString };\n inputBig: { xs: TString; lg: TString };\n inputSmall: { xs: TString; lg: TString };\n}\n\ninterface LineHeights<TString = string> {\n hero1: { xs: TString; lg: TString };\n hero2: { xs: TString; lg: TString };\n hero3: { xs: TString; lg: TString };\n\n h1: { xs: TString; lg: TString };\n h2: { xs: TString; lg: TString };\n h3: { xs: TString; lg: TString };\n h4: { xs: TString; lg: TString };\n h5: { xs: TString; lg: TString };\n h6: { xs: TString; lg: TString };\n\n text: { xs: TString; lg: TString };\n strong: { xs: TString; lg: TString };\n small: { xs: TString; lg: TString };\n\n blockquote: { xs: TString; lg: TString };\n code: { xs: TString; lg: TString };\n\n button: { xs: TString; lg: TString };\n buttonBig: { xs: TString; lg: TString };\n buttonSmall: { xs: TString; lg: TString };\n\n input: { xs: TString; lg: TString };\n inputBig: { xs: TString; lg: TString };\n inputSmall: { xs: TString; lg: TString };\n}\n\nexport interface Theme {\n breakpoints: Breakpoints;\n spacing: Spacing;\n colors: Colors;\n shadows: Shadows;\n fonts: Fonts;\n fontSizes: FontSizes;\n lineHeights: LineHeights;\n isDark: boolean;\n}\n";
3
+ export declare const themeTemplate = "\"use client\";\nimport customThemeJson from \"@/theme.json\";\n\n// Users can only override the brand palette via theme.json \u2014 semantic tokens\n// (accent, surface, etc.) are derived in GlobalStyles from the brand colors\n// and are not directly overridable.\ntype CustomColors = Omit<\n Colors,\n \"accent\" | \"accentStrong\" | \"accentMuted\" | \"surface\"\n>;\n\ninterface CustomTheme {\n default?: Partial<CustomColors>;\n dark?: Partial<CustomColors>;\n}\n\nconst customTheme = customThemeJson as CustomTheme;\n\nconst breakpoints: Breakpoints = {\n xs: 0,\n sm: 576,\n md: 768,\n lg: 992,\n xl: 1200,\n xxl: 1440,\n xxxl: 1920,\n};\n\nexport function mq(minWidth: keyof Breakpoints) {\n return `@media screen and (min-width: ${breakpoints[minWidth]}px)`;\n}\n\nconst spacing: Spacing = {\n maxWidth: { xs: \"1280px\", xxxl: \"1440px\" },\n padding: { xs: \"20px\", lg: \"40px\" },\n radius: { xs: \"6px\", lg: \"12px\", xl: \"30px\" },\n gridGap: { xs: \"20px\", lg: \"40px\" },\n};\n\n// Resolved hex palettes for each mode. They feed the literal `theme` /\n// `themeDark` objects below (swapped by Cherry's ClientThemeProvider) and\n// GlobalStyles, which also emits them as CSS custom properties on :root and\n// :root.dark for plain-CSS consumers. Custom theme.json overrides are merged\n// here, so both consumers pick them up automatically.\nexport const colorsLight: CustomColors = {\n primaryLight: \"#d1d5db\",\n primary: \"#556272\",\n primaryDark: \"#374151\",\n secondaryLight: \"#c4b5fd\",\n secondary: \"#8b5cf6\",\n secondaryDark: \"#5b21b6\",\n tertiaryLight: \"#86efac\",\n tertiary: \"#22c55e\",\n tertiaryDark: \"#15803d\",\n grayLight: \"#e5e7eb\",\n gray: \"#9ca3af\",\n grayDark: \"#4b5563\",\n success: \"#84cc16\",\n error: \"#ef4444\",\n warning: \"#eab308\",\n info: \"#06b6d4\",\n dark: \"#000000\",\n light: \"#ffffff\",\n ...(customTheme.default\n ? (customTheme.default as Partial<CustomColors>)\n : {}),\n};\n\nexport const colorsDark: CustomColors = {\n primaryLight: \"#9ca3af\",\n primary: \"#6b7280\",\n primaryDark: \"#374151\",\n secondaryLight: \"#ddd6fe\",\n secondary: \"#a78bfa\",\n secondaryDark: \"#7c3aed\",\n tertiaryLight: \"#6ee7b7\",\n tertiary: \"#10b981\",\n tertiaryDark: \"#065f46\",\n grayLight: \"#1a1a1a\",\n gray: \"#454444\",\n grayDark: \"#808080\",\n success: \"#84cc16\",\n error: \"#ef4444\",\n warning: \"#eab308\",\n info: \"#06b6d4\",\n dark: \"#ffffff\",\n light: \"#000000\",\n ...(customTheme.dark ? (customTheme.dark as Partial<CustomColors>) : {}),\n};\n\nexport const shadowsLight: Shadows = {\n xs: \"0px 4px 4px 0px rgba(18, 18, 18, 0.04), 0px 1px 3px 0px rgba(39, 41, 45, 0.02)\",\n sm: \"0px 4px 4px 0px rgba(18, 18, 18, 0.08), 0px 1px 3px 0px rgba(39, 41, 45, 0.04)\",\n md: \"0px 8px 8px 0px rgba(18, 18, 18, 0.16), 0px 2px 3px 0px rgba(39, 41, 45, 0.06)\",\n lg: \"0px 16px 24px 0px rgba(18, 18, 18, 0.20), 0px 2px 3px 0px rgba(39, 41, 45, 0.08)\",\n xl: \"0px 24px 32px 0px rgba(18, 18, 18, 0.24), 0px 2px 3px 0px rgba(39, 41, 45, 0.12)\",\n};\n\nexport const shadowsDark: Shadows = {\n xs: \"0px 4px 4px 0px rgba(255, 255, 255, 0.04), 0px 1px 3px 0px rgba(255, 255, 255, 0.02)\",\n sm: \"0px 4px 4px 0px rgba(255, 255, 255, 0.08), 0px 1px 3px 0px rgba(255, 255, 255, 0.04)\",\n md: \"0px 8px 8px 0px rgba(255, 255, 255, 0.16), 0px 2px 3px 0px rgba(255, 255, 255, 0.06)\",\n lg: \"0px 16px 24px 0px rgba(255, 255, 255, 0.20), 0px 2px 3px 0px rgba(255, 255, 255, 0.08)\",\n xl: \"0px 24px 32px 0px rgba(255, 255, 255, 0.24), 0px 2px 3px 0px rgba(255, 255, 255, 0.12)\",\n};\n\nconst fonts: Fonts = {\n text: \"Inter\",\n head: \"Inter\",\n mono: \"Roboto Mono, monospace\",\n};\n\nconst fontSizes: FontSizes = {\n hero1: { xs: \"72px\", lg: \"128px\" },\n hero2: { xs: \"60px\", lg: \"96px\" },\n hero3: { xs: \"36px\", lg: \"72px\" },\n\n h1: { xs: \"40px\", lg: \"60px\" },\n h2: { xs: \"30px\", lg: \"36px\" },\n h3: { xs: \"28px\", lg: \"30px\" },\n h4: { xs: \"24px\", lg: \"26px\" },\n h5: { xs: \"18px\", lg: \"20px\" },\n h6: { xs: \"16px\", lg: \"18px\" },\n\n text: { xs: \"14px\", lg: \"16px\" },\n strong: { xs: \"14px\", lg: \"16px\" },\n small: { xs: \"12px\", lg: \"14px\" },\n\n blockquote: { xs: \"16px\", lg: \"18px\" },\n code: { xs: \"14px\", lg: \"16px\" },\n\n button: { xs: \"16px\", lg: \"16px\" },\n buttonBig: { xs: \"18px\", lg: \"18px\" },\n buttonSmall: { xs: \"14px\", lg: \"14px\" },\n\n input: { xs: \"16px\", lg: \"16px\" },\n inputBig: { xs: \"18px\", lg: \"18px\" },\n inputSmall: { xs: \"14px\", lg: \"14px\" },\n};\n\nconst lineHeights: LineHeights = {\n hero1: { xs: \"1.1\", lg: \"1.1\" },\n hero2: { xs: \"1.1\", lg: \"1.1\" },\n hero3: { xs: \"1.17\", lg: \"1.1\" },\n\n h1: { xs: \"1\", lg: \"1.07\" },\n h2: { xs: \"1.2\", lg: \"1.2\" },\n h3: { xs: \"1.3\", lg: \"1.5\" },\n h4: { xs: \"1.3\", lg: \"1.5\" },\n h5: { xs: \"1.6\", lg: \"1.5\" },\n h6: { xs: \"1.6\", lg: \"1.6\" },\n\n text: { xs: \"1.7\", lg: \"1.7\" },\n strong: { xs: \"1.7\", lg: \"1.7\" },\n small: { xs: \"1.7\", lg: \"1.7\" },\n\n blockquote: { xs: \"1.7\", lg: \"1.7\" },\n code: { xs: \"1.7\", lg: \"1.7\" },\n\n button: { xs: \"1\", lg: \"1\" },\n buttonBig: { xs: \"1\", lg: \"1\" },\n buttonSmall: { xs: \"1\", lg: \"1\" },\n\n input: { xs: \"1\", lg: \"1\" },\n inputBig: { xs: \"1\", lg: \"1\" },\n inputSmall: { xs: \"1\", lg: \"1\" },\n};\n\n// Builds the full Colors record for a mode: the resolved brand palette plus\n// the semantic tokens derived from it. Cherry's ClientThemeProvider swaps\n// whole theme objects on toggle, so components read literal values here \u2014\n// the CSS variables emitted by GlobalStyles stay in sync via the \"dark\"\n// class for plain-CSS consumers (e.g. Callout's / Code's :root.dark blocks).\n// Exported so DemoTheme can derive preset themes with the same rules.\nexport function buildColors(palette: CustomColors, isDark: boolean): Colors {\n const accent = isDark ? palette.primaryLight : palette.primaryDark;\n return {\n ...palette,\n accent,\n // accent shifted ~10% toward black (light mode) or white (dark mode).\n accentStrong: `color-mix(in srgb, ${accent} 90%, ${isDark ? \"white\" : \"black\"})`,\n accentMuted: isDark ? palette.grayDark : palette.primary,\n surface: isDark ? palette.dark : palette.light,\n };\n}\n\nexport const theme: Theme = {\n breakpoints,\n spacing,\n colors: buildColors(colorsLight, false),\n shadows: shadowsLight,\n fonts,\n fontSizes,\n lineHeights,\n isDark: false,\n};\n\nexport const themeDark: Theme = {\n breakpoints,\n spacing,\n colors: buildColors(colorsDark, true),\n shadows: shadowsDark,\n fonts,\n fontSizes,\n lineHeights,\n isDark: true,\n};\n\ninterface Breakpoints<TNumber = number> {\n xs: TNumber;\n sm: TNumber;\n md: TNumber;\n lg: TNumber;\n xl: TNumber;\n xxl: TNumber;\n xxxl: TNumber;\n}\n\ninterface Spacing<TString = string> {\n maxWidth: { xs: TString; xxxl: TString };\n padding: { xs: TString; lg: TString };\n radius: { xs: TString; lg: TString; xl: TString };\n gridGap: { xs: TString; lg: TString };\n}\n\n// Matches cherry-styled-components' Colors shape for the brand keys, plus a\n// handful of semantic tokens derived from the brand palette by buildColors.\n// The brand keys (primaryLight\u2026light) are customizable via theme.json; the\n// semantic keys (accent*, surface) are derived and not overridable \u2014 see\n// CustomColors above.\ninterface Colors<TString = string> {\n primaryLight: TString;\n primary: TString;\n primaryDark: TString;\n\n secondaryLight: TString;\n secondary: TString;\n secondaryDark: TString;\n\n tertiaryLight: TString;\n tertiary: TString;\n tertiaryDark: TString;\n\n grayLight: TString;\n gray: TString;\n grayDark: TString;\n\n success: TString;\n error: TString;\n warning: TString;\n info: TString;\n\n dark: TString;\n light: TString;\n\n /** High-contrast accent text. Was: isDark ? primaryLight : primaryDark. */\n accent: TString;\n /** Slightly stronger accent (lightened/darkened by ~10%). */\n accentStrong: TString;\n /** Muted body text. Was: isDark ? grayDark : primary. */\n accentMuted: TString;\n /** Elevated surface color, white-ish in both modes. Was: isDark ? dark : light. */\n surface: TString;\n}\n\ninterface Shadows<TString = string> {\n xs: TString;\n sm: TString;\n md: TString;\n lg: TString;\n xl: TString;\n}\n\ninterface Fonts<TString = string> {\n head: TString;\n text: TString;\n mono: TString;\n}\n\ninterface FontSizes<TString = string> {\n hero1: { xs: TString; lg: TString };\n hero2: { xs: TString; lg: TString };\n hero3: { xs: TString; lg: TString };\n\n h1: { xs: TString; lg: TString };\n h2: { xs: TString; lg: TString };\n h3: { xs: TString; lg: TString };\n h4: { xs: TString; lg: TString };\n h5: { xs: TString; lg: TString };\n h6: { xs: TString; lg: TString };\n\n text: { xs: TString; lg: TString };\n strong: { xs: TString; lg: TString };\n small: { xs: TString; lg: TString };\n\n blockquote: { xs: TString; lg: TString };\n code: { xs: TString; lg: TString };\n\n button: { xs: TString; lg: TString };\n buttonBig: { xs: TString; lg: TString };\n buttonSmall: { xs: TString; lg: TString };\n\n input: { xs: TString; lg: TString };\n inputBig: { xs: TString; lg: TString };\n inputSmall: { xs: TString; lg: TString };\n}\n\ninterface LineHeights<TString = string> {\n hero1: { xs: TString; lg: TString };\n hero2: { xs: TString; lg: TString };\n hero3: { xs: TString; lg: TString };\n\n h1: { xs: TString; lg: TString };\n h2: { xs: TString; lg: TString };\n h3: { xs: TString; lg: TString };\n h4: { xs: TString; lg: TString };\n h5: { xs: TString; lg: TString };\n h6: { xs: TString; lg: TString };\n\n text: { xs: TString; lg: TString };\n strong: { xs: TString; lg: TString };\n small: { xs: TString; lg: TString };\n\n blockquote: { xs: TString; lg: TString };\n code: { xs: TString; lg: TString };\n\n button: { xs: TString; lg: TString };\n buttonBig: { xs: TString; lg: TString };\n buttonSmall: { xs: TString; lg: TString };\n\n input: { xs: TString; lg: TString };\n inputBig: { xs: TString; lg: TString };\n inputSmall: { xs: TString; lg: TString };\n}\n\nexport interface Theme {\n breakpoints: Breakpoints;\n spacing: Spacing;\n colors: Colors;\n shadows: Shadows;\n fonts: Fonts;\n fontSizes: FontSizes;\n lineHeights: LineHeights;\n isDark: boolean;\n}\n";
@@ -39,10 +39,11 @@ const spacing: Spacing = {
39
39
  gridGap: { xs: "20px", lg: "40px" },
40
40
  };
41
41
 
42
- // Resolved hex palettes for each mode. GlobalStyles emits these as CSS
43
- // custom properties on :root and :root.dark. Components never read these
44
- // directly they read \`theme.colors.*\` which resolves to var(--color-…)
45
- // and lets the browser pick the right value based on the active class.
42
+ // Resolved hex palettes for each mode. They feed the literal \`theme\` /
43
+ // \`themeDark\` objects below (swapped by Cherry's ClientThemeProvider) and
44
+ // GlobalStyles, which also emits them as CSS custom properties on :root and
45
+ // :root.dark for plain-CSS consumers. Custom theme.json overrides are merged
46
+ // here, so both consumers pick them up automatically.
46
47
  export const colorsLight: CustomColors = {
47
48
  primaryLight: "#d1d5db",
48
49
  primary: "#556272",
@@ -167,65 +168,46 @@ const lineHeights: LineHeights = {
167
168
  inputSmall: { xs: "1", lg: "1" },
168
169
  };
169
170
 
170
- // Single theme object exported to consumers. Every color/shadow value is a
171
- // CSS custom-property reference; the browser resolves it against :root or
172
- // :root.dark depending on the active class. Components access these exactly
173
- // like before \`theme.colors.primary\`, \`theme.shadows.sm\` but the values
174
- // flip without React re-rendering.
175
- const colors: Colors = {
176
- // Brand palette directly customizable via theme.json's "default" / "dark".
177
- primaryLight: "var(--color-primaryLight)",
178
- primary: "var(--color-primary)",
179
- primaryDark: "var(--color-primaryDark)",
180
- secondaryLight: "var(--color-secondaryLight)",
181
- secondary: "var(--color-secondary)",
182
- secondaryDark: "var(--color-secondaryDark)",
183
- tertiaryLight: "var(--color-tertiaryLight)",
184
- tertiary: "var(--color-tertiary)",
185
- tertiaryDark: "var(--color-tertiaryDark)",
186
- grayLight: "var(--color-grayLight)",
187
- gray: "var(--color-gray)",
188
- grayDark: "var(--color-grayDark)",
189
- success: "var(--color-success)",
190
- error: "var(--color-error)",
191
- warning: "var(--color-warning)",
192
- info: "var(--color-info)",
193
- dark: "var(--color-dark)",
194
- light: "var(--color-light)",
195
-
196
- // Semantic tokens — derived in GlobalStyles to capture the most common
197
- // mode-aware swaps that components used to express via \`theme.isDark ? A : B\`.
198
- // They follow the same single-noun convention as the brand palette.
199
- accent: "var(--color-accent)",
200
- accentStrong: "var(--color-accentStrong)",
201
- accentMuted: "var(--color-accentMuted)",
202
- surface: "var(--color-surface)",
203
- };
204
-
205
- const shadows: Shadows = {
206
- xs: "var(--shadow-xs)",
207
- sm: "var(--shadow-sm)",
208
- md: "var(--shadow-md)",
209
- lg: "var(--shadow-lg)",
210
- xl: "var(--shadow-xl)",
211
- };
171
+ // Builds the full Colors record for a mode: the resolved brand palette plus
172
+ // the semantic tokens derived from it. Cherry's ClientThemeProvider swaps
173
+ // whole theme objects on toggle, so components read literal values here —
174
+ // the CSS variables emitted by GlobalStyles stay in sync via the "dark"
175
+ // class for plain-CSS consumers (e.g. Callout's / Code's :root.dark blocks).
176
+ // Exported so DemoTheme can derive preset themes with the same rules.
177
+ export function buildColors(palette: CustomColors, isDark: boolean): Colors {
178
+ const accent = isDark ? palette.primaryLight : palette.primaryDark;
179
+ return {
180
+ ...palette,
181
+ accent,
182
+ // accent shifted ~10% toward black (light mode) or white (dark mode).
183
+ accentStrong: \`color-mix(in srgb, \${accent} 90%, \${isDark ? "white" : "black"})\`,
184
+ accentMuted: isDark ? palette.grayDark : palette.primary,
185
+ surface: isDark ? palette.dark : palette.light,
186
+ };
187
+ }
212
188
 
213
189
  export const theme: Theme = {
214
190
  breakpoints,
215
191
  spacing,
216
- colors,
217
- shadows,
192
+ colors: buildColors(colorsLight, false),
193
+ shadows: shadowsLight,
218
194
  fonts,
219
195
  fontSizes,
220
196
  lineHeights,
221
- // Stub for type compatibility with cherry-styled-components' Theme. Mode
222
- // switching lives entirely in CSS vars flipped by :root.dark, so our own
223
- // code never branches on this. Cherry's internal \`isDark ? … : …\` branches
224
- // (e.g. buttonStyles' filled-button text) are handled where they surface
225
- // by re-pinning to a mode-agnostic semantic token — see components/layout/Button.tsx.
226
197
  isDark: false,
227
198
  };
228
199
 
200
+ export const themeDark: Theme = {
201
+ breakpoints,
202
+ spacing,
203
+ colors: buildColors(colorsDark, true),
204
+ shadows: shadowsDark,
205
+ fonts,
206
+ fontSizes,
207
+ lineHeights,
208
+ isDark: true,
209
+ };
210
+
229
211
  interface Breakpoints<TNumber = number> {
230
212
  xs: TNumber;
231
213
  sm: TNumber;
@@ -244,7 +226,7 @@ interface Spacing<TString = string> {
244
226
  }
245
227
 
246
228
  // Matches cherry-styled-components' Colors shape for the brand keys, plus a
247
- // handful of semantic tokens derived from the brand palette by GlobalStyles.
229
+ // handful of semantic tokens derived from the brand palette by buildColors.
248
230
  // The brand keys (primaryLight…light) are customizable via theme.json; the
249
231
  // semantic keys (accent*, surface) are derived and not overridable — see
250
232
  // CustomColors above.
@@ -1 +1 @@
1
- export declare const searchDocsTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport dynamic from \"next/dynamic\";\nimport styled from \"styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport type { PageItem, MergedResult } from \"@/components/SearchModalContent\";\n\nconst SearchModalContent = dynamic(\n () =>\n import(\"@/components/SearchModalContent\").then(\n (mod) => mod.SearchModalContent,\n ),\n { ssr: false },\n);\n\ninterface SectionItem {\n label: string;\n slug: string;\n}\n\ninterface ContentHit {\n slug: string;\n snippet: string;\n}\n\ninterface SearchContextValue {\n openSearch: () => void;\n}\n\nconst SearchContext = createContext<SearchContextValue>({\n openSearch: () => {},\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\nconst StyledSearchButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: flex;\n align-items: center;\n gap: 6px;\n background: ${({ theme }) => theme.colors.light};\n color: ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 7px 8px;\n font-family: inherit;\n cursor: pointer;\n\n ${mq(\"lg\")} {\n padding: 5px 8px;\n }\n\n & svg.lucide {\n color: inherit;\n }\n`;\n\nfunction SearchProvider({\n pages,\n sections,\n children,\n}: {\n pages: PageItem[];\n sections?: SectionItem[];\n children: React.ReactNode;\n}) {\n const [isVisible, setIsVisible] = useState(false);\n const [isClosing, setIsClosing] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [activeIndex, setActiveIndex] = useState(0);\n const [contentResults, setContentResults] = useState<ContentHit[]>([]);\n const [isSearching, setIsSearching] = useState(false);\n const resultsRef = useRef<HTMLUListElement>(null);\n const closingRef = useRef(false);\n const abortRef = useRef<AbortController | null>(null);\n const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);\n const router = useRouter();\n\n const sectionLabels = useMemo(() => {\n const map: Record<string, string> = {};\n sections?.forEach((s) => {\n map[s.slug] = s.label;\n });\n return map;\n }, [sections]);\n\n const openSearch = useCallback(() => {\n closingRef.current = false;\n setIsClosing(false);\n setIsVisible(true);\n }, []);\n\n const closeSearch = useCallback(() => {\n closingRef.current = true;\n setIsClosing(true);\n if (abortRef.current) abortRef.current.abort();\n if (debounceRef.current) clearTimeout(debounceRef.current);\n }, []);\n\n const handleCloseAnimationEnd = useCallback(() => {\n if (!closingRef.current) return;\n closingRef.current = false;\n setIsVisible(false);\n setIsClosing(false);\n setQuery(\"\");\n setActiveIndex(0);\n setContentResults([]);\n setIsSearching(false);\n }, []);\n\n // Instant title/description filtering\n const titleFiltered = useMemo(() => {\n if (!query.trim()) return pages;\n const q = query.toLowerCase();\n return pages.filter(\n (p) =>\n p.title.toLowerCase().includes(q) ||\n p.description?.toLowerCase().includes(q),\n );\n }, [pages, query]);\n\n // Merge title matches with content matches\n const merged = useMemo<MergedResult[]>(() => {\n if (!query.trim()) {\n return pages.map((p) => ({ page: p }));\n }\n\n const titleMatchSlugs = new Set(titleFiltered.map((p) => p.slug));\n const titleMatches: MergedResult[] = titleFiltered.map((p) => {\n const hit = contentResults.find((cr) => cr.slug === p.slug);\n return { page: p, snippet: hit?.snippet };\n });\n\n const pageMap = new Map(pages.map((p) => [p.slug, p]));\n const contentOnly: MergedResult[] = [];\n for (const cr of contentResults) {\n if (!titleMatchSlugs.has(cr.slug)) {\n const page = pageMap.get(cr.slug);\n if (page) {\n contentOnly.push({ page, snippet: cr.snippet });\n }\n }\n }\n\n return [...titleMatches, ...contentOnly];\n }, [pages, query, titleFiltered, contentResults]);\n\n // Debounced content search\n useEffect(() => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (abortRef.current) abortRef.current.abort();\n\n const q = query.trim();\n if (q.length < 2) {\n setContentResults([]);\n setIsSearching(false);\n return;\n }\n\n setIsSearching(true);\n debounceRef.current = setTimeout(async () => {\n const controller = new AbortController();\n abortRef.current = controller;\n try {\n const res = await fetch(\n `/api/search?q=${encodeURIComponent(q)}&limit=15`,\n { signal: controller.signal },\n );\n if (!res.ok) throw new Error(\"Search failed\");\n const data = await res.json();\n setContentResults(data.results ?? []);\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setContentResults([]);\n } finally {\n if (!controller.signal.aborted) {\n setIsSearching(false);\n }\n }\n }, 300);\n\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n };\n }, [query]);\n\n const navigate = useCallback(\n (slug: string) => {\n closeSearch();\n router.push(`/${slug}`);\n },\n [closeSearch, router],\n );\n\n // Global Cmd+K / Ctrl+K listener\n const isVisibleRef = useRef(false);\n\n useEffect(() => {\n isVisibleRef.current = isVisible;\n }, [isVisible]);\n\n useEffect(() => {\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key === \"k\") {\n e.preventDefault();\n if (isVisibleRef.current) {\n closeSearch();\n } else {\n openSearch();\n }\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [closeSearch, openSearch]);\n\n // Scroll active item into view\n useEffect(() => {\n if (!resultsRef.current) return;\n const active = resultsRef.current.children[activeIndex] as HTMLElement;\n active?.scrollIntoView({ block: \"nearest\" });\n }, [activeIndex]);\n\n function handleKeyDown(e: React.KeyboardEvent) {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setActiveIndex((i) => (i < merged.length - 1 ? i + 1 : 0));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActiveIndex((i) => (i > 0 ? i - 1 : merged.length - 1));\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n if (merged[activeIndex]) {\n navigate(merged[activeIndex].page.slug);\n }\n } else if (e.key === \"Escape\") {\n closeSearch();\n }\n }\n\n return (\n <SearchContext.Provider value={{ openSearch }}>\n {children}\n {isVisible && (\n <SearchModalContent\n isClosing={isClosing}\n closeSearch={closeSearch}\n onCloseAnimationEnd={handleCloseAnimationEnd}\n query={query}\n setQuery={setQuery}\n activeIndex={activeIndex}\n setActiveIndex={setActiveIndex}\n resultsRef={resultsRef}\n onKeyDown={handleKeyDown}\n merged={merged}\n sectionLabels={sectionLabels}\n isSearching={isSearching}\n navigate={navigate}\n />\n )}\n </SearchContext.Provider>\n );\n}\n\nexport {\n SearchProvider,\n SearchContext,\n StyledKbd as SearchKbd,\n StyledSearchButton,\n};\n";
1
+ export declare const searchDocsTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport dynamic from \"next/dynamic\";\nimport styled from \"styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport type { PageItem, MergedResult } from \"@/components/SearchModalContent\";\n\nconst SearchModalContent = dynamic(\n () =>\n import(\"@/components/SearchModalContent\").then(\n (mod) => mod.SearchModalContent,\n ),\n { ssr: false },\n);\n\ninterface SectionItem {\n label: string;\n slug: string;\n}\n\n// Stable empty array so the derived contentResults keeps the same identity\n// across renders when there are no hits (memo deps stay stable).\nconst EMPTY_RESULTS: ContentHit[] = [];\n\ninterface ContentHit {\n slug: string;\n snippet: string;\n}\n\ninterface SearchContextValue {\n openSearch: () => void;\n}\n\nconst SearchContext = createContext<SearchContextValue>({\n openSearch: () => {},\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\nconst StyledSearchButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: flex;\n align-items: center;\n gap: 6px;\n background: ${({ theme }) => theme.colors.light};\n color: ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 7px 8px;\n font-family: inherit;\n cursor: pointer;\n\n ${mq(\"lg\")} {\n padding: 5px 8px;\n }\n\n & svg.lucide {\n color: inherit;\n }\n`;\n\nfunction SearchProvider({\n pages,\n sections,\n children,\n}: {\n pages: PageItem[];\n sections?: SectionItem[];\n children: React.ReactNode;\n}) {\n const [isVisible, setIsVisible] = useState(false);\n const [isClosing, setIsClosing] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [activeIndex, setActiveIndex] = useState(0);\n // Latest completed content search, keyed by the query that produced it.\n // contentResults and isSearching are derived from it at render time, so\n // the debounced-search effect never sets state synchronously (which would\n // trigger cascading renders \u2014 react-hooks/set-state-in-effect).\n const [fetched, setFetched] = useState<{\n q: string;\n results: ContentHit[];\n } | null>(null);\n const resultsRef = useRef<HTMLUListElement>(null);\n const closingRef = useRef(false);\n const abortRef = useRef<AbortController | null>(null);\n const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);\n const router = useRouter();\n\n const sectionLabels = useMemo(() => {\n const map: Record<string, string> = {};\n sections?.forEach((s) => {\n map[s.slug] = s.label;\n });\n return map;\n }, [sections]);\n\n const openSearch = useCallback(() => {\n closingRef.current = false;\n setIsClosing(false);\n setIsVisible(true);\n }, []);\n\n const closeSearch = useCallback(() => {\n closingRef.current = true;\n setIsClosing(true);\n if (abortRef.current) abortRef.current.abort();\n if (debounceRef.current) clearTimeout(debounceRef.current);\n }, []);\n\n const handleCloseAnimationEnd = useCallback(() => {\n if (!closingRef.current) return;\n closingRef.current = false;\n setIsVisible(false);\n setIsClosing(false);\n setQuery(\"\");\n setActiveIndex(0);\n setFetched(null);\n }, []);\n\n const trimmedQuery = query.trim();\n const contentResults =\n trimmedQuery.length >= 2 && fetched?.q === trimmedQuery\n ? fetched.results\n : EMPTY_RESULTS;\n const isSearching = trimmedQuery.length >= 2 && fetched?.q !== trimmedQuery;\n\n // Instant title/description filtering\n const titleFiltered = useMemo(() => {\n if (!query.trim()) return pages;\n const q = query.toLowerCase();\n return pages.filter(\n (p) =>\n p.title.toLowerCase().includes(q) ||\n p.description?.toLowerCase().includes(q),\n );\n }, [pages, query]);\n\n // Merge title matches with content matches\n const merged = useMemo<MergedResult[]>(() => {\n if (!query.trim()) {\n return pages.map((p) => ({ page: p }));\n }\n\n const titleMatchSlugs = new Set(titleFiltered.map((p) => p.slug));\n const titleMatches: MergedResult[] = titleFiltered.map((p) => {\n const hit = contentResults.find((cr) => cr.slug === p.slug);\n return { page: p, snippet: hit?.snippet };\n });\n\n const pageMap = new Map(pages.map((p) => [p.slug, p]));\n const contentOnly: MergedResult[] = [];\n for (const cr of contentResults) {\n if (!titleMatchSlugs.has(cr.slug)) {\n const page = pageMap.get(cr.slug);\n if (page) {\n contentOnly.push({ page, snippet: cr.snippet });\n }\n }\n }\n\n return [...titleMatches, ...contentOnly];\n }, [pages, query, titleFiltered, contentResults]);\n\n // Debounced content search. State updates happen only inside the timeout's\n // async callback; short queries need no reset because the derived values\n // above ignore results whose query no longer matches.\n useEffect(() => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (abortRef.current) abortRef.current.abort();\n\n const q = query.trim();\n if (q.length < 2) return;\n\n debounceRef.current = setTimeout(async () => {\n const controller = new AbortController();\n abortRef.current = controller;\n try {\n const res = await fetch(\n `/api/search?q=${encodeURIComponent(q)}&limit=15`,\n { signal: controller.signal },\n );\n if (!res.ok) throw new Error(\"Search failed\");\n const data = await res.json();\n setFetched({ q, results: data.results ?? [] });\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setFetched({ q, results: [] });\n }\n }, 300);\n\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n };\n }, [query]);\n\n const navigate = useCallback(\n (slug: string) => {\n closeSearch();\n router.push(`/${slug}`);\n },\n [closeSearch, router],\n );\n\n // Global Cmd+K / Ctrl+K listener\n const isVisibleRef = useRef(false);\n\n useEffect(() => {\n isVisibleRef.current = isVisible;\n }, [isVisible]);\n\n useEffect(() => {\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key === \"k\") {\n e.preventDefault();\n if (isVisibleRef.current) {\n closeSearch();\n } else {\n openSearch();\n }\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [closeSearch, openSearch]);\n\n // Scroll active item into view\n useEffect(() => {\n if (!resultsRef.current) return;\n const active = resultsRef.current.children[activeIndex] as HTMLElement;\n active?.scrollIntoView({ block: \"nearest\" });\n }, [activeIndex]);\n\n function handleKeyDown(e: React.KeyboardEvent) {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setActiveIndex((i) => (i < merged.length - 1 ? i + 1 : 0));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActiveIndex((i) => (i > 0 ? i - 1 : merged.length - 1));\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n if (merged[activeIndex]) {\n navigate(merged[activeIndex].page.slug);\n }\n } else if (e.key === \"Escape\") {\n closeSearch();\n }\n }\n\n return (\n <SearchContext.Provider value={{ openSearch }}>\n {children}\n {isVisible && (\n <SearchModalContent\n isClosing={isClosing}\n closeSearch={closeSearch}\n onCloseAnimationEnd={handleCloseAnimationEnd}\n query={query}\n setQuery={setQuery}\n activeIndex={activeIndex}\n setActiveIndex={setActiveIndex}\n resultsRef={resultsRef}\n onKeyDown={handleKeyDown}\n merged={merged}\n sectionLabels={sectionLabels}\n isSearching={isSearching}\n navigate={navigate}\n />\n )}\n </SearchContext.Provider>\n );\n}\n\nexport {\n SearchProvider,\n SearchContext,\n StyledKbd as SearchKbd,\n StyledSearchButton,\n};\n";
@@ -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 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\";\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 ${({ 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,7 +1,11 @@
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";
@@ -70,54 +74,40 @@ const Dot = styled.span<{ theme: Theme }>\`
70
74
  }
71
75
  \`;
72
76
 
77
+ /* Icon-only copy button. interactiveStyles supplies the border highlight on
78
+ hover plus the focus/active rings (no scale effect); the GitHub-style
79
+ copied/base colors stay fixed like the rest of the code block. The dark
80
+ block re-declares the hover border because its higher-specificity
81
+ :root.dark & border-color would otherwise override the mixin's hover. */
73
82
  const CopyButton = styled.button<{ theme: Theme; $copied: boolean }>\`
83
+ \${resetButton}
84
+ \${interactiveStyles}
74
85
  background: \${({ $copied }) =>
75
86
  $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")};
87
+ border-color: \${({ $copied }) => ($copied ? "#2da44e" : "rgba(0, 0, 0, 0.1)")};
79
88
  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;
89
+ padding: 4px;
85
90
  display: flex;
86
91
  align-items: center;
87
- gap: 4px;
92
+ justify-content: center;
88
93
  margin-right: -6px;
89
94
 
90
95
  & svg.lucide {
91
96
  color: \${({ $copied }) => ($copied ? "#2da44e" : "#57606a")};
92
97
  }
93
98
 
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
99
  :root.dark & {
106
100
  background: \${({ $copied }) =>
107
101
  $copied ? "rgba(126, 231, 135, 0.2)" : "transparent"};
108
102
  border-color: \${({ $copied }) =>
109
103
  $copied ? "#7ee787" : "rgba(255, 255, 255, 0.1)"};
110
- color: \${({ $copied }) => ($copied ? "#7ee787" : "#c9d1d9")};
111
104
 
112
105
  & svg.lucide {
113
106
  color: \${({ $copied }) => ($copied ? "#7ee787" : "#c9d1d9")};
114
107
  }
115
108
 
116
109
  &: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)"};
110
+ border-color: \${({ theme }) => theme.colors.primary};
121
111
  }
122
112
  }
123
113
  \`;
@@ -363,18 +353,13 @@ function Code({ code, language = "javascript", theme, className }: CodeProps) {
363
353
  <Dot theme={theme} />
364
354
  <Dot theme={theme} />
365
355
  </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
- )}
356
+ <CopyButton
357
+ onClick={handleCopy}
358
+ $copied={copied}
359
+ theme={theme}
360
+ aria-label={copied ? "Copied" : "Copy code"}
361
+ >
362
+ <Icon name={copied ? "check" : "copy"} size={12} />
378
363
  </CopyButton>
379
364
  </TopBar>
380
365
  <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";
@@ -1,6 +1,16 @@
1
1
  export const demoThemeTemplate = `"use client";
2
+ import { useContext } from "react";
3
+ import { useTheme } from "styled-components";
4
+ import { ThemeContext } from "cherry-styled-components";
5
+ import {
6
+ buildColors,
7
+ colorsLight,
8
+ colorsDark,
9
+ theme,
10
+ themeDark,
11
+ Theme,
12
+ } from "@/app/theme";
2
13
  import { Button } from "@/components/layout/Button";
3
- import { useThemeOverride } from "@/components/layout/ClientThemeProvider";
4
14
  import { Columns } from "@/components/layout/Columns";
5
15
 
6
16
  interface DemoThemeProps {
@@ -9,11 +19,13 @@ interface DemoThemeProps {
9
19
 
10
20
  type Palette = Record<string, string>;
11
21
 
12
- // Each preset is light + dark color overrides applied directly to CSS custom
13
- // properties on <html>. This mirrors what a user editing theme.json would see:
14
- // the variable names are identical, the variables flip light vs dark via the
15
- // "dark" class on <html>, and Reset clears the overrides so the defaults take
16
- // over again.
22
+ // Each preset is light + dark color overrides, mirroring what a user editing
23
+ // theme.json would see. Applying one does two things: swaps in a theme object
24
+ // rebuilt with the overridden palette (Cherry's setTheme, which also persists
25
+ // the mode), and mirrors the overrides onto the --color-* CSS custom
26
+ // properties on <html> for the plain-CSS consumers that read variables
27
+ // (e.g. Callout's and Code's :root.dark blocks). Reset clears both so the
28
+ // defaults take over again.
17
29
  const PRESETS: Record<
18
30
  DemoThemeProps["variant"],
19
31
  { light: Palette; dark: Palette; label: string }
@@ -61,28 +73,47 @@ const PRESETS: Record<
61
73
 
62
74
  const PRESET_KEYS = ["primaryLight", "primary", "primaryDark"] as const;
63
75
 
64
- function applyPreset(palette: Palette) {
76
+ function applyPresetVars(palette: Palette) {
65
77
  const root = document.documentElement;
66
78
  for (const key of PRESET_KEYS) {
67
79
  if (palette[key]) root.style.setProperty(\`--color-\${key}\`, palette[key]);
68
80
  }
69
81
  }
70
82
 
71
- function clearPreset() {
83
+ function clearPresetVars() {
72
84
  const root = document.documentElement;
73
85
  for (const key of PRESET_KEYS) {
74
86
  root.style.removeProperty(\`--color-\${key}\`);
75
87
  }
76
88
  }
77
89
 
90
+ // Rebuilds a mode's theme with the preset palette merged in, so the semantic
91
+ // tokens (accent, accentStrong, …) derive from the preset colors exactly like
92
+ // they would from a theme.json override.
93
+ function presetTheme(palette: Palette, dark: boolean): Theme {
94
+ const base = dark ? themeDark : theme;
95
+ const paletteBase = dark ? colorsDark : colorsLight;
96
+ return {
97
+ ...base,
98
+ colors: buildColors({ ...paletteBase, ...palette }, dark),
99
+ };
100
+ }
101
+
78
102
  function DemoTheme({ variant }: DemoThemeProps) {
79
- const { setMode } = useThemeOverride();
103
+ const { setTheme } = useContext(ThemeContext);
104
+ const activeTheme = useTheme() as Theme;
80
105
  const preset = PRESETS[variant];
81
106
 
82
107
  if (!preset) {
83
108
  return (
84
109
  <Columns cols={2}>
85
- <Button onClick={() => clearPreset()} fullWidth>
110
+ <Button
111
+ onClick={() => {
112
+ clearPresetVars();
113
+ setTheme(activeTheme.isDark ? themeDark : theme);
114
+ }}
115
+ fullWidth
116
+ >
86
117
  Reset to Default
87
118
  </Button>
88
119
  </Columns>
@@ -93,8 +124,8 @@ function DemoTheme({ variant }: DemoThemeProps) {
93
124
  <Columns cols={2}>
94
125
  <Button
95
126
  onClick={() => {
96
- applyPreset(preset.light);
97
- setMode("light");
127
+ applyPresetVars(preset.light);
128
+ setTheme(presetTheme(preset.light, false));
98
129
  }}
99
130
  icon="sun"
100
131
  fullWidth
@@ -104,8 +135,8 @@ function DemoTheme({ variant }: DemoThemeProps) {
104
135
  <Button
105
136
  outline
106
137
  onClick={() => {
107
- applyPreset(preset.dark);
108
- setMode("dark");
138
+ applyPresetVars(preset.dark);
139
+ setTheme(presetTheme(preset.dark, true));
109
140
  }}
110
141
  icon="moon-star"
111
142
  fullWidth
@@ -1 +1 @@
1
- export declare const headerTemplate = "\"use client\";\nimport React from \"react\";\nimport { useCallback, useContext, useRef, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useOnClickOutside } from \"@/components/ClickOutside\";\nimport { Search } from \"lucide-react\";\nimport { Logo } from \"@/components/layout/Pictograms\";\nimport { ChatContext, ChatButtonCTA } from \"@/components/Chat\";\nimport {\n SearchContext,\n SearchKbd,\n StyledSearchButton,\n} from \"@/components/SearchDocs\";\nimport themeJson from \"@/theme.json\";\n\nconst customThemeJson = themeJson as typeof themeJson & {\n logo?: { dark: string; light: string };\n};\n\nconst StyledHeader = styled.header<{ theme: Theme; $hasChildren: boolean }>`\n position: sticky;\n top: 0;\n margin: 0;\n z-index: 1000;\n width: 100%;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n ${({ $hasChildren }) =>\n !$hasChildren &&\n css`\n ${mq(\"lg\")} {\n padding-bottom: 16px;\n padding-top: 16px;\n }\n `}\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n background: ${({ theme }) => theme.colors.light};\n z-index: -2;\n }\n\n &::after {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 5%, transparent)`};\n z-index: -1;\n }\n\n & .logo {\n display: flex;\n\n & svg,\n & img {\n margin: auto;\n height: auto;\n width: fit-content;\n min-width: fit-content;\n max-width: 182px;\n max-height: 30px;\n\n & path[fill] {\n fill: ${({ theme }) => theme.colors.primary};\n }\n }\n }\n`;\n\nconst StyledHeaderInner = styled.div<{ $hasChildren: boolean }>`\n display: flex;\n align-items: center;\n justify-content: space-between;\n flex-wrap: wrap;\n padding: 16px 0 0 20px;\n\n ${({ $hasChildren }) =>\n !$hasChildren &&\n css`\n padding-bottom: 16px;\n `}\n\n ${mq(\"lg\")} {\n flex-wrap: nowrap;\n padding: 0 20px;\n }\n`;\n\nconst StyledLeftWrapper = styled.div`\n display: flex;\n align-items: center;\n gap: 10px;\n min-width: fit-content;\n padding-right: 20px;\n\n ${mq(\"lg\")} {\n padding-right: 0;\n }\n`;\n\ninterface HeaderProps {\n children?: React.ReactNode;\n}\n\nfunction Header({ children }: HeaderProps) {\n const [isOptionActive, setIsOptionActive] = useState(false);\n const [isLangActive, setIsLangActive] = useState(false);\n\n const wrapperRef = useRef<HTMLSpanElement>(null);\n const elmRef = useRef<HTMLDivElement>(null);\n const langRef = useRef<HTMLSpanElement>(null);\n const closeMenu = useCallback(() => {\n setIsOptionActive(false);\n setIsLangActive(false);\n }, []);\n\n useOnClickOutside(\n [elmRef, wrapperRef],\n isOptionActive ? closeMenu : () => {},\n );\n useOnClickOutside([langRef, wrapperRef], isLangActive ? closeMenu : () => {});\n const { isChatActive } = useContext(ChatContext);\n const { openSearch } = useContext(SearchContext);\n\n return (\n <StyledHeader $hasChildren={children ? true : false} id=\"header\">\n <StyledHeaderInner $hasChildren={children ? true : false}>\n <Link href=\"/\" className=\"logo\" aria-label=\"Logo\">\n {customThemeJson.logo ? (\n <>\n {/* Both logos render; .light-only and .dark-only classes in\n GlobalStyles hide the inactive one based on the \"dark\" class\n on <html>. Avoids a JS-driven swap so no flash on first load. */}\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n className=\"light-only\"\n src={customThemeJson.logo.light}\n alt=\"Logo\"\n width=\"100\"\n height=\"100\"\n />\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n className=\"dark-only\"\n src={customThemeJson.logo.dark}\n alt=\"Logo\"\n width=\"100\"\n height=\"100\"\n />\n </>\n ) : (\n <Logo />\n )}\n </Link>\n {children}\n <StyledLeftWrapper>\n <StyledSearchButton onClick={openSearch} aria-label=\"Search docs\">\n <Search size={14} />\n <SearchKbd>&#8984;K</SearchKbd>\n </StyledSearchButton>\n {isChatActive && <ChatButtonCTA />}\n </StyledLeftWrapper>\n </StyledHeaderInner>\n </StyledHeader>\n );\n}\n\nexport { Header };\n";
1
+ export declare const headerTemplate = "\"use client\";\nimport React from \"react\";\nimport { useCallback, useContext, useRef, useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useOnClickOutside } from \"cherry-styled-components\";\nimport { Search } from \"lucide-react\";\nimport { Logo } from \"@/components/layout/Pictograms\";\nimport { ChatContext, ChatButtonCTA } from \"@/components/Chat\";\nimport {\n SearchContext,\n SearchKbd,\n StyledSearchButton,\n} from \"@/components/SearchDocs\";\nimport themeJson from \"@/theme.json\";\n\nconst customThemeJson = themeJson as typeof themeJson & {\n logo?: { dark: string; light: string };\n};\n\nconst StyledHeader = styled.header<{ theme: Theme; $hasChildren: boolean }>`\n position: sticky;\n top: 0;\n margin: 0;\n z-index: 1000;\n width: 100%;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n ${({ $hasChildren }) =>\n !$hasChildren &&\n css`\n ${mq(\"lg\")} {\n padding-bottom: 16px;\n padding-top: 16px;\n }\n `}\n\n &::before,\n &::after {\n display: block;\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n background: ${({ theme }) => theme.colors.light};\n z-index: -2;\n }\n\n &::after {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 5%, transparent)`};\n z-index: -1;\n }\n\n & .logo {\n display: flex;\n\n & svg,\n & img {\n margin: auto;\n height: auto;\n width: fit-content;\n min-width: fit-content;\n max-width: 182px;\n max-height: 30px;\n\n & path[fill] {\n fill: ${({ theme }) => theme.colors.primary};\n }\n }\n }\n`;\n\nconst StyledHeaderInner = styled.div<{ $hasChildren: boolean }>`\n display: flex;\n align-items: center;\n justify-content: space-between;\n flex-wrap: wrap;\n padding: 16px 0 0 20px;\n\n ${({ $hasChildren }) =>\n !$hasChildren &&\n css`\n padding-bottom: 16px;\n `}\n\n ${mq(\"lg\")} {\n flex-wrap: nowrap;\n padding: 0 20px;\n }\n`;\n\nconst StyledLeftWrapper = styled.div`\n display: flex;\n align-items: center;\n gap: 10px;\n min-width: fit-content;\n padding-right: 20px;\n\n ${mq(\"lg\")} {\n padding-right: 0;\n }\n`;\n\ninterface HeaderProps {\n children?: React.ReactNode;\n}\n\nfunction Header({ children }: HeaderProps) {\n const [isOptionActive, setIsOptionActive] = useState(false);\n const [isLangActive, setIsLangActive] = useState(false);\n\n const wrapperRef = useRef<HTMLSpanElement>(null);\n const elmRef = useRef<HTMLDivElement>(null);\n const langRef = useRef<HTMLSpanElement>(null);\n const closeMenu = useCallback(() => {\n setIsOptionActive(false);\n setIsLangActive(false);\n }, []);\n\n useOnClickOutside(\n [elmRef, wrapperRef],\n isOptionActive ? closeMenu : () => {},\n );\n useOnClickOutside([langRef, wrapperRef], isLangActive ? closeMenu : () => {});\n const { isChatActive } = useContext(ChatContext);\n const { openSearch } = useContext(SearchContext);\n\n return (\n <StyledHeader $hasChildren={children ? true : false} id=\"header\">\n <StyledHeaderInner $hasChildren={children ? true : false}>\n <Link href=\"/\" className=\"logo\" aria-label=\"Logo\">\n {customThemeJson.logo ? (\n <>\n {/* Both logos render; .light-only and .dark-only classes in\n GlobalStyles hide the inactive one based on the \"dark\" class\n on <html>. Avoids a JS-driven swap so no flash on first load. */}\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n className=\"light-only\"\n src={customThemeJson.logo.light}\n alt=\"Logo\"\n width=\"100\"\n height=\"100\"\n />\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n className=\"dark-only\"\n src={customThemeJson.logo.dark}\n alt=\"Logo\"\n width=\"100\"\n height=\"100\"\n />\n </>\n ) : (\n <Logo />\n )}\n </Link>\n {children}\n <StyledLeftWrapper>\n <StyledSearchButton onClick={openSearch} aria-label=\"Search docs\">\n <Search size={14} />\n <SearchKbd>&#8984;K</SearchKbd>\n </StyledSearchButton>\n {isChatActive && <ChatButtonCTA />}\n </StyledLeftWrapper>\n </StyledHeaderInner>\n </StyledHeader>\n );\n}\n\nexport { Header };\n";
@@ -4,7 +4,7 @@ import { useCallback, useContext, useRef, useState } from "react";
4
4
  import styled, { css } from "styled-components";
5
5
  import Link from "next/link";
6
6
  import { mq, Theme } from "@/app/theme";
7
- import { useOnClickOutside } from "@/components/ClickOutside";
7
+ import { useOnClickOutside } from "cherry-styled-components";
8
8
  import { Search } from "lucide-react";
9
9
  import { Logo } from "@/components/layout/Pictograms";
10
10
  import { ChatContext, ChatButtonCTA } from "@/components/Chat";
@@ -1 +1 @@
1
- export declare const siteGateComponentTemplate = "\"use client\";\nimport { Suspense, useState, type FormEvent } from \"react\";\nimport styled from \"styled-components\";\nimport { Input } from \"cherry-styled-components\";\nimport { Lock } from \"lucide-react\";\nimport { Theme } from \"@/app/theme\";\nimport { Button } from \"@/components/layout/Button\";\nimport { Callout } from \"@/components/layout/Callout\";\nimport {\n ToggleTheme,\n ToggleThemeLoading,\n} from \"@/components/layout/ThemeToggle\";\nimport { config } from \"@/utils/config\";\n\nconst StyledWrapper = styled.div<{ theme: Theme }>`\n min-height: 100dvh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n background: ${({ theme }) => theme.colors.light};\n`;\n\nconst StyledInner = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n max-width: 380px;\n`;\n\nconst StyledFooter = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 12px;\n`;\n\nconst StyledBranding = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 14px;\n color: ${({ theme }) => theme.colors.grayDark};\n\n & a {\n color: ${({ theme }) => theme.colors.accent};\n font-weight: 700;\n text-decoration: none;\n transition: all 0.3s ease;\n }\n\n & a:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nconst StyledCard = styled.div<{ theme: Theme }>`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n padding: 40px 32px;\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n text-align: center;\n`;\n\nconst StyledIcon = styled.div<{ theme: Theme }>`\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 48px;\n height: 48px;\n border-radius: 50%;\n color: ${({ theme }) => theme.colors.accent};\n background: ${({ theme }) => theme.colors.grayLight};\n\n & svg {\n width: 22px;\n height: 22px;\n }\n`;\n\nconst StyledTitle = styled.h1<{ theme: Theme }>`\n margin: 0;\n font-size: 22px;\n color: ${({ theme }) => theme.colors.accent};\n`;\n\nconst StyledLede = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 15px;\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nconst StyledForm = styled.form`\n display: flex;\n flex-direction: column;\n gap: 20px;\n width: 100%;\n`;\n\nconst StyledAlert = styled.div`\n width: 100%;\n text-align: left;\n`;\n\nexport function SiteGate({ hideBranding }: { hideBranding?: boolean }) {\n const [password, setPassword] = useState(\"\");\n const [status, setStatus] = useState<\"idle\" | \"loading\" | \"error\">(\"idle\");\n\n async function handleSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (!password || status === \"loading\") return;\n\n setStatus(\"loading\");\n try {\n const res = await fetch(\"/api/gate\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n });\n if (res.ok) {\n window.location.reload();\n return;\n }\n setStatus(\"error\");\n } catch {\n setStatus(\"error\");\n }\n }\n\n return (\n <StyledWrapper>\n <StyledInner>\n <StyledCard>\n <StyledIcon>\n <Lock />\n </StyledIcon>\n <StyledTitle>{config.name || \"Documentation\"}</StyledTitle>\n <StyledLede>This site is password protected.</StyledLede>\n <StyledForm onSubmit={handleSubmit} noValidate>\n <Input\n type=\"password\"\n name=\"password\"\n autoComplete=\"current-password\"\n placeholder=\"Enter password\"\n aria-label=\"Password\"\n autoFocus\n value={password}\n $error={status === \"error\"}\n $fullWidth\n onChange={(e) => {\n setPassword(e.target.value);\n if (status === \"error\") setStatus(\"idle\");\n }}\n />\n <Button\n type=\"submit\"\n fullWidth\n disabled={status === \"loading\" || !password}\n >\n {status === \"loading\" ? \"Unlocking...\" : \"Enter\"}\n </Button>\n </StyledForm>\n {status === \"error\" && (\n <StyledAlert>\n <Callout type=\"danger\">\n <p>Incorrect password. Try again.</p>\n </Callout>\n </StyledAlert>\n )}\n </StyledCard>\n <StyledFooter>\n <Suspense fallback={<ToggleThemeLoading />}>\n <ToggleTheme />\n </Suspense>\n {!hideBranding && (\n <StyledBranding>\n Powered by <a href=\"https://doccupine.com\">Doccupine</a>\n </StyledBranding>\n )}\n </StyledFooter>\n </StyledInner>\n </StyledWrapper>\n );\n}\n";
1
+ export declare const siteGateComponentTemplate = "\"use client\";\nimport { useState, type FormEvent } from \"react\";\nimport styled from \"styled-components\";\nimport { Input, ThemeToggle } from \"cherry-styled-components\";\nimport { Lock } from \"lucide-react\";\nimport { Theme } from \"@/app/theme\";\nimport { Button } from \"@/components/layout/Button\";\nimport { Callout } from \"@/components/layout/Callout\";\nimport { config } from \"@/utils/config\";\n\nconst StyledWrapper = styled.div<{ theme: Theme }>`\n min-height: 100dvh;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 20px;\n background: ${({ theme }) => theme.colors.light};\n`;\n\nconst StyledInner = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n max-width: 380px;\n`;\n\nconst StyledFooter = styled.div`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 12px;\n`;\n\nconst StyledBranding = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 14px;\n color: ${({ theme }) => theme.colors.grayDark};\n\n & a {\n color: ${({ theme }) => theme.colors.accent};\n font-weight: 700;\n text-decoration: none;\n transition: all 0.3s ease;\n }\n\n & a:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nconst StyledCard = styled.div<{ theme: Theme }>`\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 20px;\n width: 100%;\n padding: 40px 32px;\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n text-align: center;\n`;\n\nconst StyledIcon = styled.div<{ theme: Theme }>`\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 48px;\n height: 48px;\n border-radius: 50%;\n color: ${({ theme }) => theme.colors.accent};\n background: ${({ theme }) => theme.colors.grayLight};\n\n & svg {\n width: 22px;\n height: 22px;\n }\n`;\n\nconst StyledTitle = styled.h1<{ theme: Theme }>`\n margin: 0;\n font-size: 22px;\n color: ${({ theme }) => theme.colors.accent};\n`;\n\nconst StyledLede = styled.p<{ theme: Theme }>`\n margin: 0;\n font-size: 15px;\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nconst StyledForm = styled.form`\n display: flex;\n flex-direction: column;\n gap: 20px;\n width: 100%;\n`;\n\nconst StyledAlert = styled.div`\n width: 100%;\n text-align: left;\n`;\n\nexport function SiteGate({ hideBranding }: { hideBranding?: boolean }) {\n const [password, setPassword] = useState(\"\");\n const [status, setStatus] = useState<\"idle\" | \"loading\" | \"error\">(\"idle\");\n\n async function handleSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (!password || status === \"loading\") return;\n\n setStatus(\"loading\");\n try {\n const res = await fetch(\"/api/gate\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ password }),\n });\n if (res.ok) {\n window.location.reload();\n return;\n }\n setStatus(\"error\");\n } catch {\n setStatus(\"error\");\n }\n }\n\n return (\n <StyledWrapper>\n <StyledInner>\n <StyledCard>\n <StyledIcon>\n <Lock />\n </StyledIcon>\n <StyledTitle>{config.name || \"Documentation\"}</StyledTitle>\n <StyledLede>This site is password protected.</StyledLede>\n <StyledForm onSubmit={handleSubmit} noValidate>\n <Input\n type=\"password\"\n name=\"password\"\n autoComplete=\"current-password\"\n placeholder=\"Enter password\"\n aria-label=\"Password\"\n autoFocus\n value={password}\n $error={status === \"error\"}\n $fullWidth\n onChange={(e) => {\n setPassword(e.target.value);\n if (status === \"error\") setStatus(\"idle\");\n }}\n />\n <Button\n type=\"submit\"\n fullWidth\n disabled={status === \"loading\" || !password}\n >\n {status === \"loading\" ? \"Unlocking...\" : \"Enter\"}\n </Button>\n </StyledForm>\n {status === \"error\" && (\n <StyledAlert>\n <Callout type=\"danger\">\n <p>Incorrect password. Try again.</p>\n </Callout>\n </StyledAlert>\n )}\n </StyledCard>\n <StyledFooter>\n <ThemeToggle />\n {!hideBranding && (\n <StyledBranding>\n Powered by <a href=\"https://doccupine.com\">Doccupine</a>\n </StyledBranding>\n )}\n </StyledFooter>\n </StyledInner>\n </StyledWrapper>\n );\n}\n";
@@ -1,15 +1,11 @@
1
1
  export const siteGateComponentTemplate = `"use client";
2
- import { Suspense, useState, type FormEvent } from "react";
2
+ import { useState, type FormEvent } from "react";
3
3
  import styled from "styled-components";
4
- import { Input } from "cherry-styled-components";
4
+ import { Input, ThemeToggle } from "cherry-styled-components";
5
5
  import { Lock } from "lucide-react";
6
6
  import { Theme } from "@/app/theme";
7
7
  import { Button } from "@/components/layout/Button";
8
8
  import { Callout } from "@/components/layout/Callout";
9
- import {
10
- ToggleTheme,
11
- ToggleThemeLoading,
12
- } from "@/components/layout/ThemeToggle";
13
9
  import { config } from "@/utils/config";
14
10
 
15
11
  const StyledWrapper = styled.div<{ theme: Theme }>\`
@@ -175,9 +171,7 @@ export function SiteGate({ hideBranding }: { hideBranding?: boolean }) {
175
171
  )}
176
172
  </StyledCard>
177
173
  <StyledFooter>
178
- <Suspense fallback={<ToggleThemeLoading />}>
179
- <ToggleTheme />
180
- </Suspense>
174
+ <ThemeToggle />
181
175
  {!hideBranding && (
182
176
  <StyledBranding>
183
177
  Powered by <a href="https://doccupine.com">Doccupine</a>
@@ -17,13 +17,13 @@ export const packageJsonTemplate = JSON.stringify({
17
17
  "@mdx-js/react": "^3.1.1",
18
18
  "@modelcontextprotocol/sdk": "^1.29.0",
19
19
  "@posthog/react": "^1.10.3",
20
- "cherry-styled-components": "^0.2.0",
20
+ "cherry-styled-components": "^0.2.5",
21
21
  langchain: "^1.5.2",
22
22
  "lucide-react": "^1.23.0",
23
23
  minisearch: "^7.2.0",
24
24
  next: "16.2.10",
25
25
  "next-mdx-remote": "^6.0.0",
26
- "posthog-js": "^1.396.5",
26
+ "posthog-js": "^1.396.6",
27
27
  "posthog-node": "^5.39.4",
28
28
  react: "19.2.7",
29
29
  "react-dom": "19.2.7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.95",
3
+ "version": "0.0.96",
4
4
  "description": "Free and open-source documentation platform. Write MDX, get a production-ready site with AI chat, built-in components, and an MCP server - in one command.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {