Chris Tate преди 5 месеца
родител
ревизия
b1c2a016d0

+ 37 - 1
apps/web/app/globals.css

@@ -5,6 +5,29 @@
 
 :root {
   --radius: 0.5rem;
+  /* Monochrome light theme */
+  --background: oklch(1.0 0 0);
+  --foreground: oklch(0.1 0 0);
+  --card: oklch(0.98 0 0);
+  --card-foreground: oklch(0.1 0 0);
+  --popover: oklch(0.98 0 0);
+  --popover-foreground: oklch(0.1 0 0);
+  --primary: oklch(0.1 0 0);
+  --primary-foreground: oklch(1.0 0 0);
+  --secondary: oklch(0.92 0 0);
+  --secondary-foreground: oklch(0.1 0 0);
+  --muted: oklch(0.92 0 0);
+  --muted-foreground: oklch(0.45 0 0);
+  --accent: oklch(0.92 0 0);
+  --accent-foreground: oklch(0.1 0 0);
+  --destructive: oklch(0.55 0.2 25);
+  --destructive-foreground: oklch(1.0 0 0);
+  --border: oklch(0.85 0 0);
+  --input: oklch(0.85 0 0);
+  --ring: oklch(0.6 0 0);
+}
+
+.dark {
   /* Monochrome dark theme */
   --background: oklch(0.0 0 0);
   --foreground: oklch(0.98 0 0);
@@ -75,7 +98,7 @@
 
   pre {
     font-family: var(--font-geist-mono), ui-monospace, monospace;
-    @apply bg-card border border-border rounded-lg p-4 overflow-x-auto text-sm leading-relaxed;
+    @apply overflow-x-auto text-sm leading-relaxed;
   }
 
   pre code {
@@ -104,3 +127,16 @@
 button {
   cursor: pointer;
 }
+
+/* Shiki dual theme support */
+.shiki,
+.shiki span {
+  color: var(--shiki-light) !important;
+  background-color: var(--shiki-light-bg) !important;
+}
+
+.dark .shiki,
+.dark .shiki span {
+  color: var(--shiki-dark) !important;
+  background-color: var(--shiki-dark-bg) !important;
+}

+ 9 - 6
apps/web/app/layout.tsx

@@ -3,6 +3,7 @@ import localFont from "next/font/local";
 import "./globals.css";
 import { Header } from "@/components/header";
 import { Footer } from "@/components/footer";
+import { ThemeProvider } from "@/components/theme-provider";
 
 const geistSans = localFont({
   src: "./fonts/GeistVF.woff",
@@ -25,13 +26,15 @@ export default function RootLayout({
   children: React.ReactNode;
 }>) {
   return (
-    <html lang="en">
+    <html lang="en" suppressHydrationWarning>
       <body className={`${geistSans.variable} ${geistMono.variable}`}>
-        <div className="min-h-screen flex flex-col">
-          <Header />
-          <main className="flex-1">{children}</main>
-          <Footer />
-        </div>
+        <ThemeProvider>
+          <div className="min-h-screen flex flex-col">
+            <Header />
+            <main className="flex-1">{children}</main>
+            <Footer />
+          </div>
+        </ThemeProvider>
       </body>
     </html>
   );

+ 18 - 7
apps/web/app/page.tsx

@@ -2,6 +2,7 @@ import Link from "next/link";
 import { Button } from "@/components/ui/button";
 import { Demo } from "@/components/demo";
 import { Code } from "@/components/code";
+import { CopyButton } from "@/components/copy-button";
 
 export default function Home() {
   return (
@@ -27,6 +28,13 @@ export default function Home() {
               target="_blank"
               rel="noopener noreferrer"
             >
+              <svg
+                viewBox="0 0 24 24"
+                fill="currentColor"
+                className="w-5 h-5"
+              >
+                <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
+              </svg>
               GitHub
             </a>
           </Button>
@@ -69,7 +77,7 @@ export default function Home() {
             <div>
               <h2 className="text-2xl font-semibold mb-4">Define your catalog</h2>
               <p className="text-muted-foreground mb-6">
-                Components, actions, and validation functions. All type-safe with Zod.
+                Components, actions, and validation functions.
               </p>
               <Code lang="typescript">{`import { createCatalog } from '@json-render/core';
 import { z } from 'zod';
@@ -151,12 +159,15 @@ export const catalog = createCatalog({
       <section className="border-t border-border">
         <div className="max-w-4xl mx-auto px-6 py-24 text-center">
           <h2 className="text-2xl font-semibold mb-4">Get started</h2>
-          <pre className="inline-block text-left mb-8 text-sm">
-            <code>npm install @json-render/core @json-render/react</code>
-          </pre>
-          <Button asChild>
-            <Link href="/docs">Documentation</Link>
-          </Button>
+          <div className="flex items-center justify-center gap-2 border border-border rounded px-4 py-3 mb-8 mx-auto w-fit">
+            <code className="text-sm bg-transparent">npm install @json-render/core @json-render/react</code>
+            <CopyButton text="npm install @json-render/core @json-render/react" />
+          </div>
+          <div>
+            <Button asChild>
+              <Link href="/docs">Documentation</Link>
+            </Button>
+          </div>
         </div>
       </section>
     </>

+ 71 - 5
apps/web/components/code-block.tsx

@@ -4,8 +4,8 @@ import { useEffect, useState } from "react";
 import { createHighlighter, type Highlighter } from "shiki";
 import { CopyButton } from "./copy-button";
 
-const vercelTheme = {
-  name: "vercel",
+const vercelDarkTheme = {
+  name: "vercel-dark",
   type: "dark" as const,
   colors: {
     "editor.background": "transparent",
@@ -63,13 +63,72 @@ const vercelTheme = {
   ],
 };
 
+const vercelLightTheme = {
+  name: "vercel-light",
+  type: "light" as const,
+  colors: {
+    "editor.background": "transparent",
+    "editor.foreground": "#171717",
+  },
+  settings: [
+    {
+      scope: ["comment", "punctuation.definition.comment"],
+      settings: { foreground: "#6b7280" },
+    },
+    {
+      scope: ["string", "string.quoted", "string.template"],
+      settings: { foreground: "#067a6e" },
+    },
+    {
+      scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
+      settings: { foreground: "#067a6e" },
+    },
+    {
+      scope: ["keyword", "storage.type", "storage.modifier"],
+      settings: { foreground: "#d6409f" },
+    },
+    {
+      scope: ["keyword.operator", "keyword.control"],
+      settings: { foreground: "#d6409f" },
+    },
+    {
+      scope: ["entity.name.function", "support.function", "meta.function-call"],
+      settings: { foreground: "#6e56cf" },
+    },
+    {
+      scope: ["variable", "variable.other", "variable.parameter"],
+      settings: { foreground: "#171717" },
+    },
+    {
+      scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
+      settings: { foreground: "#d6409f" },
+    },
+    {
+      scope: ["punctuation", "meta.brace", "meta.bracket"],
+      settings: { foreground: "#6b7280" },
+    },
+    {
+      scope: ["support.type.property-name", "entity.name.tag.json", "meta.object-literal.key"],
+      settings: { foreground: "#171717" },
+    },
+    {
+      scope: ["entity.other.attribute-name"],
+      settings: { foreground: "#067a6e" },
+    },
+    {
+      scope: ["support.type.primitive", "entity.name.type.primitive"],
+      settings: { foreground: "#067a6e" },
+    },
+  ],
+};
+
 // Preload highlighter on module load
 let highlighterPromise: Promise<Highlighter> | null = null;
 
 function getHighlighter() {
   if (!highlighterPromise) {
     highlighterPromise = createHighlighter({
-      themes: [vercelTheme],
+      themes: [vercelLightTheme, vercelDarkTheme],
       langs: ["json", "tsx", "typescript"],
     });
   }
@@ -91,7 +150,14 @@ export function CodeBlock({ code, lang }: CodeBlockProps) {
 
   useEffect(() => {
     getHighlighter().then((highlighter) => {
-      setHtml(highlighter.codeToHtml(code, { lang, theme: "vercel" }));
+      setHtml(highlighter.codeToHtml(code, {
+        lang,
+        themes: {
+          light: "vercel-light",
+          dark: "vercel-dark",
+        },
+        defaultColor: false,
+      }));
     });
   }, [code, lang]);
 
@@ -104,7 +170,7 @@ export function CodeBlock({ code, lang }: CodeBlockProps) {
       <div className="sticky top-0 float-right z-10">
         <CopyButton
           text={code}
-          className="opacity-0 group-hover:opacity-100 text-muted-foreground bg-card"
+          className="opacity-0 group-hover:opacity-100 text-neutral-400"
         />
       </div>
       <div

+ 70 - 7
apps/web/components/code.tsx

@@ -1,8 +1,8 @@
 import { codeToHtml } from "shiki";
 import { CopyButton } from "./copy-button";
 
-const vercelTheme = {
-  name: "vercel",
+const vercelDarkTheme = {
+  name: "vercel-dark",
   type: "dark" as const,
   colors: {
     "editor.background": "transparent",
@@ -60,6 +60,65 @@ const vercelTheme = {
   ],
 };
 
+const vercelLightTheme = {
+  name: "vercel-light",
+  type: "light" as const,
+  colors: {
+    "editor.background": "transparent",
+    "editor.foreground": "#171717",
+  },
+  settings: [
+    {
+      scope: ["comment", "punctuation.definition.comment"],
+      settings: { foreground: "#6b7280" },
+    },
+    {
+      scope: ["string", "string.quoted", "string.template"],
+      settings: { foreground: "#067a6e" },
+    },
+    {
+      scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
+      settings: { foreground: "#067a6e" },
+    },
+    {
+      scope: ["keyword", "storage.type", "storage.modifier"],
+      settings: { foreground: "#d6409f" },
+    },
+    {
+      scope: ["keyword.operator", "keyword.control"],
+      settings: { foreground: "#d6409f" },
+    },
+    {
+      scope: ["entity.name.function", "support.function", "meta.function-call"],
+      settings: { foreground: "#6e56cf" },
+    },
+    {
+      scope: ["variable", "variable.other", "variable.parameter"],
+      settings: { foreground: "#171717" },
+    },
+    {
+      scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
+      settings: { foreground: "#d6409f" },
+    },
+    {
+      scope: ["punctuation", "meta.brace", "meta.bracket"],
+      settings: { foreground: "#6b7280" },
+    },
+    {
+      scope: ["support.type.property-name", "entity.name.tag.json", "meta.object-literal.key"],
+      settings: { foreground: "#171717" },
+    },
+    {
+      scope: ["entity.other.attribute-name"],
+      settings: { foreground: "#067a6e" },
+    },
+    {
+      scope: ["support.type.primitive", "entity.name.type.primitive"],
+      settings: { foreground: "#067a6e" },
+    },
+  ],
+};
+
 interface CodeProps {
   children: string;
   lang?: "json" | "tsx" | "typescript" | "bash" | "javascript";
@@ -68,18 +127,22 @@ interface CodeProps {
 export async function Code({ children, lang = "typescript" }: CodeProps) {
   const html = await codeToHtml(children.trim(), {
     lang,
-    theme: vercelTheme,
+    themes: {
+      light: vercelLightTheme,
+      dark: vercelDarkTheme,
+    },
+    defaultColor: false,
   });
 
   return (
-    <div className="group relative my-6 rounded-lg border border-border bg-card p-4 overflow-x-auto text-sm [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_code]:bg-transparent! font-mono">
-      <div className="sticky top-0 float-right z-10 -mt-1 -mr-1">
+    <div className="group relative my-6 rounded-lg border border-border bg-neutral-100 dark:bg-[#0a0a0a] text-sm font-mono overflow-hidden">
+      <div className="absolute top-3 right-3 z-10">
         <CopyButton
           text={children.trim()}
-          className="opacity-0 group-hover:opacity-100 text-muted-foreground bg-card"
+          className="opacity-0 group-hover:opacity-100 text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-[#0a0a0a]"
         />
       </div>
-      <div dangerouslySetInnerHTML={{ __html: html }} />
+      <div className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!" dangerouslySetInnerHTML={{ __html: html }} />
     </div>
   );
 }

+ 1 - 1
apps/web/components/copy-button.tsx

@@ -19,7 +19,7 @@ export function CopyButton({ text, className = "" }: CopyButtonProps) {
   return (
     <button
       onClick={handleCopy}
-      className={`p-1.5 rounded hover:bg-white/10 transition-colors ${className}`}
+      className={`p-1.5 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors ${className}`}
       aria-label="Copy code"
     >
       {copied ? (

+ 12 - 12
apps/web/components/demo.tsx

@@ -185,10 +185,10 @@ export function Demo() {
   const showLoadingDots = isStreamingSimulation || isStreaming;
 
   return (
-    <div className="w-full max-w-4xl mx-auto">
+    <div className="w-full max-w-4xl mx-auto text-left">
       {/* Prompt input */}
       <div className="mb-6">
-        <div className="border border-border rounded p-3 bg-card font-mono text-sm min-h-[44px] flex items-center justify-between">
+        <div className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between">
           {mode === "simulation" ? (
             <div className="flex items-center flex-1">
               <span className="inline-flex items-center h-5">{typedPrompt}</span>
@@ -219,29 +219,29 @@ export function Demo() {
           {(mode === "simulation" || isStreaming) ? (
             <button
               onClick={stopGeneration}
-              className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors"
+              className="ml-2 w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
               aria-label="Stop"
             >
               <svg
-                width="14"
-                height="14"
+                width="16"
+                height="16"
                 viewBox="0 0 24 24"
                 fill="currentColor"
                 stroke="none"
               >
-                <rect x="6" y="6" width="12" height="12" rx="1" />
+                <rect x="6" y="6" width="12" height="12" />
               </svg>
             </button>
           ) : (
             <button
               onClick={handleSubmit}
               disabled={!userPrompt.trim()}
-              className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30"
+              className="ml-2 w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
               aria-label="Submit"
             >
               <svg
-                width="14"
-                height="14"
+                width="16"
+                height="16"
                 viewBox="0 0 24 24"
                 fill="none"
                 stroke="currentColor"
@@ -255,7 +255,7 @@ export function Demo() {
             </button>
           )}
         </div>
-        <div className="mt-2 text-xs text-muted-foreground">
+        <div className="mt-2 text-xs text-muted-foreground text-center">
           Try: &quot;Create a login form&quot; or &quot;Build a feedback form with rating&quot;
         </div>
       </div>
@@ -276,7 +276,7 @@ export function Demo() {
               </button>
             ))}
           </div>
-          <div className="border border-border rounded p-3 bg-card font-mono text-xs h-96 overflow-auto text-left">
+          <div className="border border-border rounded p-3 bg-background font-mono text-xs h-96 overflow-auto text-left">
             <div className={activeTab === "stream" ? "" : "hidden"}>
               {streamLines.length > 0 ? (
                 <>
@@ -307,7 +307,7 @@ export function Demo() {
         {/* Rendered output using json-render */}
         <div>
           <div className="text-xs text-muted-foreground mb-2 font-mono">render</div>
-          <div className="border border-border rounded p-3 bg-card h-96 overflow-auto flex flex-col">
+          <div className="border border-border rounded p-3 bg-background h-96 overflow-auto flex flex-col">
             {currentTree && currentTree.root ? (
               <div className="animate-in fade-in duration-200 w-full flex flex-col items-center py-4">
                 <div className="my-auto">

+ 1 - 1
apps/web/components/demo/checkbox.tsx

@@ -15,7 +15,7 @@ export function Checkbox({ element }: ComponentRenderProps) {
       onClick={() => setChecked((prev) => !prev)}
     >
       <div
-        className={`w-3.5 h-3.5 border border-border rounded-sm flex items-center justify-center transition-colors ${checked ? "bg-foreground" : "bg-card"}`}
+        className={`w-3.5 h-3.5 border border-border rounded-sm flex items-center justify-center transition-colors ${checked ? "bg-foreground" : "bg-background"}`}
       >
         {checked && (
           <svg

+ 1 - 1
apps/web/components/demo/image.tsx

@@ -17,7 +17,7 @@ export function Image({ element }: ComponentRenderProps) {
 
   return (
     <div
-      className={`bg-card border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground aspect-video ${baseClass} ${customClass}`}
+      className={`bg-muted border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground aspect-video ${baseClass} ${customClass}`}
       style={imgStyle}
     >
       {(props.alt as string) || "img"}

+ 1 - 1
apps/web/components/demo/input.tsx

@@ -17,7 +17,7 @@ export function Input({ element }: ComponentRenderProps) {
       <input
         type={(props.type as string) || "text"}
         placeholder={(props.placeholder as string) || ""}
-        className="h-7 w-full bg-card border border-border rounded px-2 text-xs focus:outline-none focus:ring-1 focus:ring-foreground/20"
+        className="h-7 w-full bg-background border border-border rounded px-2 text-xs focus:outline-none focus:ring-1 focus:ring-foreground/20"
       />
     </div>
   );

+ 2 - 2
apps/web/components/demo/select.tsx

@@ -26,7 +26,7 @@ export function Select({ element }: ComponentRenderProps) {
       ) : null}
       <div
         onClick={() => setOpenSelectValue(isOpen ? null : key)}
-        className="h-7 w-full bg-card border border-border rounded px-2 text-xs flex items-center justify-between cursor-pointer hover:border-foreground/30 transition-colors"
+        className="h-7 w-full bg-background border border-border rounded px-2 text-xs flex items-center justify-between cursor-pointer hover:border-foreground/30 transition-colors"
       >
         <span
           className={
@@ -50,7 +50,7 @@ export function Select({ element }: ComponentRenderProps) {
         </svg>
       </div>
       {isOpen && options.length > 0 && (
-        <div className="absolute z-10 top-full left-0 right-0 mt-1 bg-card border border-border rounded shadow-lg overflow-hidden">
+        <div className="absolute z-10 top-full left-0 right-0 mt-1 bg-background border border-border rounded shadow-lg overflow-hidden">
           {options.map((opt, i) => (
             <div
               key={i}

+ 1 - 1
apps/web/components/demo/textarea.tsx

@@ -18,7 +18,7 @@ export function Textarea({ element }: ComponentRenderProps) {
       <textarea
         placeholder={(props.placeholder as string) || ""}
         rows={rows}
-        className="w-full bg-card border border-border rounded px-2 py-1 text-xs resize-none focus:outline-none focus:ring-1 focus:ring-foreground/20"
+        className="w-full bg-background border border-border rounded px-2 py-1 text-xs resize-none focus:outline-none focus:ring-1 focus:ring-foreground/20"
       />
     </div>
   );

+ 3 - 1
apps/web/components/header.tsx

@@ -1,4 +1,5 @@
 import Link from "next/link";
+import { ThemeToggle } from "./theme-toggle";
 
 export function Header() {
   return (
@@ -7,7 +8,7 @@ export function Header() {
         <Link href="/" className="font-semibold hover:opacity-70 transition-opacity">
           json-render
         </Link>
-        <nav className="flex gap-6 items-center text-sm">
+        <nav className="flex gap-4 items-center text-sm">
           <Link
             href="/docs"
             className="text-muted-foreground hover:text-foreground transition-colors"
@@ -22,6 +23,7 @@ export function Header() {
           >
             GitHub
           </a>
+          <ThemeToggle />
         </nav>
       </div>
     </header>

+ 16 - 0
apps/web/components/theme-provider.tsx

@@ -0,0 +1,16 @@
+"use client";
+
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+  return (
+    <NextThemesProvider
+      attribute="class"
+      defaultTheme="dark"
+      enableSystem
+      disableTransitionOnChange
+    >
+      {children}
+    </NextThemesProvider>
+  );
+}

+ 61 - 0
apps/web/components/theme-toggle.tsx

@@ -0,0 +1,61 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
+
+export function ThemeToggle() {
+  const { theme, setTheme } = useTheme();
+  const [mounted, setMounted] = useState(false);
+
+  useEffect(() => {
+    setMounted(true);
+  }, []);
+
+  if (!mounted) {
+    return <div className="w-8 h-8" />;
+  }
+
+  return (
+    <button
+      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
+      className="w-8 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
+      aria-label="Toggle theme"
+    >
+      {theme === "dark" ? (
+        <svg
+          width="16"
+          height="16"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+        >
+          <circle cx="12" cy="12" r="4" />
+          <path d="M12 2v2" />
+          <path d="M12 20v2" />
+          <path d="m4.93 4.93 1.41 1.41" />
+          <path d="m17.66 17.66 1.41 1.41" />
+          <path d="M2 12h2" />
+          <path d="M20 12h2" />
+          <path d="m6.34 17.66-1.41 1.41" />
+          <path d="m19.07 4.93-1.41 1.41" />
+        </svg>
+      ) : (
+        <svg
+          width="16"
+          height="16"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+        >
+          <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
+        </svg>
+      )}
+    </button>
+  );
+}

+ 4 - 1
apps/web/components/ui/sonner.tsx

@@ -1,11 +1,14 @@
 "use client"
 
+import { useTheme } from "next-themes"
 import { Toaster as Sonner, type ToasterProps } from "sonner"
 
 const Toaster = ({ ...props }: ToasterProps) => {
+  const { resolvedTheme } = useTheme()
+
   return (
     <Sonner
-      theme="light"
+      theme={resolvedTheme as ToasterProps["theme"]}
       className="toaster group"
       style={
         {