diff --git a/apps/notebook/eslint.config.mjs b/apps/notebook/eslint.config.mjs new file mode 100644 index 0000000..6d22b8e --- /dev/null +++ b/apps/notebook/eslint.config.mjs @@ -0,0 +1,18 @@ +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; +import nextTypescript from "eslint-config-next/typescript"; + +const eslintConfig = [ + ...nextCoreWebVitals, + ...nextTypescript, + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, +]; + +export default eslintConfig; diff --git a/apps/notebook/next.config.ts b/apps/notebook/next.config.ts new file mode 100644 index 0000000..cb651cd --- /dev/null +++ b/apps/notebook/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/apps/notebook/package.json b/apps/notebook/package.json new file mode 100644 index 0000000..36ed1c4 --- /dev/null +++ b/apps/notebook/package.json @@ -0,0 +1,32 @@ +{ + "name": "@repo/notebook", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack --port 3001", + "build": "next build", + "start": "next start", + "lint": "eslint ." + }, + "dependencies": { + "next": "16.1.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-markdown": "^9.0.3", + "remark-gfm": "^4.0.0", + "@codesandbox/sandpack-react": "^2.19.0", + "shiki": "^3.2.0", + "mermaid": "^11.4.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/apps/notebook/postcss.config.mjs b/apps/notebook/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/apps/notebook/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/apps/notebook/src/app/globals.css b/apps/notebook/src/app/globals.css new file mode 100644 index 0000000..e04cb82 --- /dev/null +++ b/apps/notebook/src/app/globals.css @@ -0,0 +1,299 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +/* === Brand Design Tokens === */ +:root { + --background: #F7F7F9; + --foreground: #010507; + + --color-lilac: #BEC2FF; + --color-lilac-light: #D4D7FF; + --color-lilac-dark: #9599CC; + --color-mint: #85E0CE; + --color-mint-light: #A8E9DC; + --color-mint-dark: #1B936F; + + --color-surface: #DEDEE9; + --color-surface-light: #F7F7F9; + --color-container: #FFFFFF; + + --color-text-primary: #010507; + --color-text-secondary: #57575B; + --color-text-tertiary: #8E8E93; + + --color-border: #DBDBE5; + --color-border-light: #EBEBF0; + --color-border-glass: rgba(255, 255, 255, 0.3); + + --color-glass: rgba(255, 255, 255, 0.7); + --color-glass-subtle: rgba(255, 255, 255, 0.5); + --color-glass-dark: rgba(255, 255, 255, 0.85); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 10px 25px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.03); + --shadow-glass: 0 4px 30px rgba(0, 0, 0, 0.1); + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 24px; + + --font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; + + --surface-primary: #fff; + --surface-secondary: #fafafa; + --text-primary: #374151; + --text-secondary: #6b7280; + --text-tertiary: #9ca3af; + + --border-default: #e5e7eb; + --border-subtle: #f0f0f0; + + color-scheme: light dark; +} + +/* === Dark Mode === */ +:root.dark { + --background: #0a0a0a; + --foreground: #ededed; + + --color-surface: #1a1a2e; + --color-surface-light: #0f0f1a; + --color-container: #1a1a2e; + + --color-text-primary: #e5e7eb; + --color-text-secondary: #9ca3af; + --color-text-tertiary: #6b7280; + + --color-border: rgba(255, 255, 255, 0.1); + --color-border-light: rgba(255, 255, 255, 0.06); + --color-border-glass: rgba(255, 255, 255, 0.08); + + --color-glass: rgba(20, 20, 40, 0.7); + --color-glass-subtle: rgba(20, 20, 40, 0.5); + --color-glass-dark: rgba(10, 10, 20, 0.85); + + --shadow-glass: 0 4px 30px rgba(0, 0, 0, 0.3); + + --surface-primary: rgba(255, 255, 255, 0.04); + --surface-secondary: rgba(255, 255, 255, 0.02); + + --text-primary: #e5e7eb; + --text-secondary: #9ca3af; + --text-tertiary: #6b7280; + + --border-default: rgba(255, 255, 255, 0.08); + --border-subtle: rgba(255, 255, 255, 0.06); +} + +/* === Base === */ +body { + font-family: var(--font-family); + background: var(--background); + color: var(--foreground); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +body, html { + height: 100%; +} + +/* === Animated Background === */ +.abstract-bg { + position: fixed; + inset: 0; + overflow: hidden; + z-index: 0; + background: linear-gradient(135deg, var(--color-surface-light) 0%, var(--color-surface) 100%); +} + +.abstract-bg::before, +.abstract-bg::after { + content: ''; + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.4; +} + +.abstract-bg::before { + width: 600px; + height: 600px; + background: var(--color-lilac); + top: -200px; + right: -100px; + animation: blob1 25s ease-in-out infinite; +} + +.abstract-bg::after { + width: 500px; + height: 500px; + background: var(--color-mint); + bottom: -150px; + left: -100px; + animation: blob2 30s ease-in-out infinite; +} + +@keyframes blob1 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 25% { transform: translate(-30px, 50px) scale(1.1); } + 50% { transform: translate(20px, -30px) scale(0.95); } + 75% { transform: translate(40px, 20px) scale(1.05); } +} + +@keyframes blob2 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(50px, -40px) scale(1.1); } + 66% { transform: translate(-30px, 30px) scale(0.9); } +} + +/* === Glassmorphism === */ +.glass { + background: var(--color-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--color-border-glass); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-glass); +} + +.glass-subtle { + background: var(--color-glass-subtle); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--color-border-glass); + border-radius: var(--radius-lg); +} + +/* === Gradient Utilities === */ +.text-gradient { + background: linear-gradient(135deg, var(--color-lilac-dark), var(--color-mint-dark)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* === Notebook-specific === */ +.notebook-cell { + background: var(--color-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--color-border-glass); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: box-shadow 0.2s ease; +} + +.notebook-cell:hover { + box-shadow: var(--shadow-md); +} + +/* Code block styling */ +.code-block { + border-left: 3px solid var(--color-lilac); + overflow-x: auto; +} + +.code-block pre { + margin: 0; + padding: var(--space-4); + font-size: 13px; + line-height: 1.6; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +.code-block code { + font-family: inherit; +} + +/* Markdown cell prose */ +.markdown-prose h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.75rem; } +.markdown-prose h2 { font-size: 1.375rem; font-weight: 600; margin-bottom: 0.5rem; margin-top: 1.5rem; } +.markdown-prose h3 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; margin-top: 1rem; } +.markdown-prose p { margin-bottom: 0.75rem; color: var(--text-primary); } +.markdown-prose ul, .markdown-prose ol { margin-bottom: 0.75rem; padding-left: 1.5rem; } +.markdown-prose li { margin-bottom: 0.25rem; color: var(--text-primary); } +.markdown-prose strong { font-weight: 600; } +.markdown-prose a { color: var(--color-lilac-dark); text-decoration: underline; } +.markdown-prose code { + background: var(--color-glass-subtle); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.875em; + font-family: 'SF Mono', 'Fira Code', monospace; +} +.markdown-prose blockquote { + border-left: 3px solid var(--color-lilac); + padding: var(--space-3) var(--space-4); + margin: 0.75rem 0; + background: var(--color-glass-subtle); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.markdown-prose blockquote p { margin: 0; color: var(--text-secondary); } +.markdown-prose hr { + border: none; + border-top: 1px solid var(--color-border-light); + margin: 1.5rem 0; +} + +/* Sidebar */ +.sidebar-link { + transition: all 0.15s ease; + border-left: 2px solid transparent; +} + +.sidebar-link:hover { + background: var(--color-glass-subtle); +} + +.sidebar-link--active { + border-left-color: var(--color-lilac-dark); + background: var(--color-glass-subtle); + color: var(--color-text-primary); + font-weight: 600; +} + +/* Playground */ +.playground-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + min-height: 300px; + overflow: hidden; +} + +@media (max-width: 768px) { + .playground-container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} + +/* Shimmer loading */ +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.shimmer-loading { + background: linear-gradient( + 90deg, + var(--color-glass-subtle) 25%, + var(--color-glass) 50%, + var(--color-glass-subtle) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} diff --git a/apps/notebook/src/app/layout.tsx b/apps/notebook/src/app/layout.tsx new file mode 100644 index 0000000..6669ed6 --- /dev/null +++ b/apps/notebook/src/app/layout.tsx @@ -0,0 +1,28 @@ +"use client"; + +import "./globals.css"; +import { ThemeProvider } from "@/hooks/use-theme"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + OpenGenerativeUI Notebook + + + + + + + {children} + + + ); +} diff --git a/apps/notebook/src/app/page.tsx b/apps/notebook/src/app/page.tsx new file mode 100644 index 0000000..aa1cb7a --- /dev/null +++ b/apps/notebook/src/app/page.tsx @@ -0,0 +1,5 @@ +import { NotebookShell } from "@/components/notebook-shell"; + +export default function Page() { + return ; +} diff --git a/apps/notebook/src/components/cell-renderer.tsx b/apps/notebook/src/components/cell-renderer.tsx new file mode 100644 index 0000000..631fb1a --- /dev/null +++ b/apps/notebook/src/components/cell-renderer.tsx @@ -0,0 +1,55 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { Cell } from "@/lib/types"; +import { MarkdownCell } from "./markdown-cell"; +import { CodeCell } from "./code-cell"; + +const PlaygroundCell = dynamic( + () => import("./playground-cell").then((m) => m.PlaygroundCell), + { + ssr: false, + loading: () => ( +
+
+
+ ), + } +); + +const MermaidCell = dynamic( + () => import("./mermaid-cell").then((m) => m.MermaidCell), + { + ssr: false, + loading: () => ( +
+
+
+ ), + } +); + +export function CellRenderer({ cell }: { cell: Cell }) { + switch (cell.type) { + case "markdown": + return ; + case "code": + return ( + + ); + case "playground": + return ( + + ); + case "mermaid": + return ; + } +} diff --git a/apps/notebook/src/components/chapter-header.tsx b/apps/notebook/src/components/chapter-header.tsx new file mode 100644 index 0000000..a6fa99a --- /dev/null +++ b/apps/notebook/src/components/chapter-header.tsx @@ -0,0 +1,30 @@ +"use client"; + +export function ChapterHeader({ + icon, + title, + description, +}: { + icon: string; + title: string; + description: string; +}) { + return ( +
+ + {icon} + +
+

{title}

+

+ {description} +

+
+
+ ); +} diff --git a/apps/notebook/src/components/code-cell.tsx b/apps/notebook/src/components/code-cell.tsx new file mode 100644 index 0000000..8e26d85 --- /dev/null +++ b/apps/notebook/src/components/code-cell.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getHighlighter } from "@/lib/highlight"; +import { CopyButton } from "./copy-button"; + +export function CodeCell({ + content, + language, + filename, +}: { + content: string; + language: string; + filename?: string; +}) { + const [html, setHtml] = useState(""); + const [isDark, setIsDark] = useState(false); + + useEffect(() => { + const check = () => + setIsDark(document.documentElement.classList.contains("dark")); + check(); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + let cancelled = false; + getHighlighter().then((highlighter) => { + if (cancelled) return; + const theme = isDark ? "github-dark" : "github-light"; + const highlighted = highlighter.codeToHtml(content.trim(), { + lang: language, + theme, + }); + setHtml(highlighted); + }); + return () => { + cancelled = true; + }; + }, [content, language, isDark]); + + return ( +
+ {filename && ( +
+ {filename} +
+ )} + + {html ? ( +
+ ) : ( +
+
+            {content.trim()}
+          
+
+ )} +
+ ); +} diff --git a/apps/notebook/src/components/copy-button.tsx b/apps/notebook/src/components/copy-button.tsx new file mode 100644 index 0000000..d848575 --- /dev/null +++ b/apps/notebook/src/components/copy-button.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useState } from "react"; + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} diff --git a/apps/notebook/src/components/markdown-cell.tsx b/apps/notebook/src/components/markdown-cell.tsx new file mode 100644 index 0000000..ed900c9 --- /dev/null +++ b/apps/notebook/src/components/markdown-cell.tsx @@ -0,0 +1,31 @@ +"use client"; + +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +export function MarkdownCell({ content }: { content: string }) { + return ( +
+
+ ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {content} +
+
+
+ ); +} diff --git a/apps/notebook/src/components/mermaid-cell.tsx b/apps/notebook/src/components/mermaid-cell.tsx new file mode 100644 index 0000000..a80927e --- /dev/null +++ b/apps/notebook/src/components/mermaid-cell.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useState, useId } from "react"; +import mermaid from "mermaid"; + +let initialized = false; + +function initMermaid(isDark: boolean) { + mermaid.initialize({ + startOnLoad: false, + theme: isDark ? "dark" : "default", + fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", + fontSize: 14, + }); + initialized = true; +} + +export function MermaidCell({ + content, + title, +}: { + content: string; + title?: string; +}) { + const [svg, setSvg] = useState(""); + const [error, setError] = useState(""); + const [isDark, setIsDark] = useState(false); + const reactId = useId(); + const safeId = "mermaid-" + reactId.replace(/:/g, "-"); + + // Dark mode detection + useEffect(() => { + const check = () => + setIsDark(document.documentElement.classList.contains("dark")); + check(); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + // Render diagram + useEffect(() => { + let cancelled = false; + + async function render() { + try { + // Re-initialize to pick up theme change + initMermaid(isDark); + + const { svg: rendered } = await mermaid.render(safeId, content.trim()); + if (!cancelled) { + setSvg(rendered); + setError(""); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to render diagram"); + setSvg(""); + } + // Clean up orphaned element mermaid may have left + const orphan = document.getElementById("d" + safeId); + orphan?.remove(); + } + } + + render(); + return () => { + cancelled = true; + }; + }, [content, isDark, safeId]); + + return ( +
+ {title && ( +
+ + ◆ + + {title} +
+ )} + +
+ {error ? ( +
+ Diagram error: {error} +
+ ) : svg ? ( +
+ ) : ( +
+ )} +
+
+ ); +} diff --git a/apps/notebook/src/components/notebook-shell.tsx b/apps/notebook/src/components/notebook-shell.tsx new file mode 100644 index 0000000..7736a04 --- /dev/null +++ b/apps/notebook/src/components/notebook-shell.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { chapters } from "@/content"; +import { SidebarNav } from "./sidebar-nav"; +import { ChapterHeader } from "./chapter-header"; +import { CellRenderer } from "./cell-renderer"; +import { ThemeToggle } from "./theme-toggle"; + +export function NotebookShell() { + return ( +
+ {/* Animated background */} +
+ + + + {/* Main content */} +
+ {/* Top bar */} +
+ +
+ + {/* Chapters */} +
+ {chapters.map((chapter) => ( +
+ +
+ {chapter.cells.map((cell) => ( + + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/notebook/src/components/playground-cell.tsx b/apps/notebook/src/components/playground-cell.tsx new file mode 100644 index 0000000..7f2ce33 --- /dev/null +++ b/apps/notebook/src/components/playground-cell.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { + SandpackProvider, + SandpackCodeEditor, + SandpackPreview, + SandpackLayout, + useSandpack, +} from "@codesandbox/sandpack-react"; +import { useEffect, useState, useCallback } from "react"; + +function ResetButton() { + const { sandpack } = useSandpack(); + return ( + + ); +} + +export function PlaygroundCell({ + files, + dependencies, + title, +}: { + files: Record; + dependencies?: Record; + title?: string; +}) { + const [isDark, setIsDark] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const check = () => + setIsDark(document.documentElement.classList.contains("dark")); + check(); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + + // Close fullscreen on Escape + useEffect(() => { + if (!isFullscreen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsFullscreen(false); + }; + document.addEventListener("keydown", handler); + // Prevent body scroll while fullscreen + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", handler); + document.body.style.overflow = ""; + }; + }, [isFullscreen]); + + const toggleFullscreen = useCallback(() => setIsFullscreen((v) => !v), []); + + const sandpackTheme = { + colors: { + surface1: isDark ? "#1a1a2e" : "#ffffff", + surface2: isDark ? "#0f0f1a" : "#f7f7f9", + surface3: isDark ? "#2a2a4a" : "#ebebf0", + clickable: isDark ? "#9ca3af" : "#6b7280", + base: isDark ? "#e5e7eb" : "#374151", + disabled: isDark ? "#4b5563" : "#d1d5db", + hover: isDark ? "#d1d5db" : "#111827", + accent: isDark ? "#BEC2FF" : "#9599CC", + error: "#ef4444", + errorSurface: isDark ? "#451a1a" : "#fef2f2", + }, + font: { + body: "'Plus Jakarta Sans', system-ui, sans-serif", + mono: "'SF Mono', 'Fira Code', monospace", + size: "13px", + lineHeight: "1.6", + }, + }; + + const wrapperClass = isFullscreen + ? "fixed inset-0 z-50 flex flex-col" + : "notebook-cell overflow-hidden"; + + const wrapperStyle = isFullscreen + ? { + background: isDark ? "#0a0a0a" : "#ffffff", + borderColor: "var(--color-mint)", + } + : { borderColor: "var(--color-mint)" }; + + return ( +
+ {/* Header bar */} + {title && ( +
+ + ▶ + + + {title} + + +
+ )} + + + + + + +
+ + + Edit the code and see changes live + +
+
+
+ ); +} diff --git a/apps/notebook/src/components/sidebar-nav.tsx b/apps/notebook/src/components/sidebar-nav.tsx new file mode 100644 index 0000000..0cdf082 --- /dev/null +++ b/apps/notebook/src/components/sidebar-nav.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Chapter } from "@/lib/types"; + +export function SidebarNav({ chapters }: { chapters: Chapter[] }) { + const [activeId, setActiveId] = useState(chapters[0]?.id ?? ""); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + const visible = entries.find((e) => e.isIntersecting); + if (visible) setActiveId(visible.target.id); + }, + { rootMargin: "-80px 0px -60% 0px", threshold: 0.1 } + ); + + chapters.forEach((ch) => { + const el = document.getElementById(ch.id); + if (el) observer.observe(el); + }); + + return () => observer.disconnect(); + }, [chapters]); + + const scrollTo = (id: string) => { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth" }); + setIsOpen(false); + }; + + return ( + <> + {/* Mobile hamburger */} + + + {/* Overlay for mobile */} + {isOpen && ( +
setIsOpen(false)} + /> + )} + + {/* Sidebar */} + + + ); +} diff --git a/apps/notebook/src/components/theme-toggle.tsx b/apps/notebook/src/components/theme-toggle.tsx new file mode 100644 index 0000000..e47743a --- /dev/null +++ b/apps/notebook/src/components/theme-toggle.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useTheme } from "@/hooks/use-theme"; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const isDark = + theme === "dark" || + (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches); + + return ( + + ); +} diff --git a/apps/notebook/src/content/chapters/01-introduction.ts b/apps/notebook/src/content/chapters/01-introduction.ts new file mode 100644 index 0000000..3180fba --- /dev/null +++ b/apps/notebook/src/content/chapters/01-introduction.ts @@ -0,0 +1,84 @@ +import type { Chapter } from "@/lib/types"; + +export const introduction: Chapter = { + id: "introduction", + title: "Introduction", + description: "What OpenGenerativeUI is and how the pieces connect.", + icon: "🪁", + cells: [ + { + type: "markdown", + id: "intro-what", + content: `# OpenGenerativeUI + +OpenGenerativeUI is an open-source template showing how an AI agent can **generate rich, interactive UI** — not just text — using [CopilotKit](https://copilotkit.ai) and [LangGraph](https://langchain-ai.github.io/langgraph/). + +The agent produces charts, 3D scenes, SVG diagrams, and interactive widgets that render directly in the chat stream. Users and the agent share the same application state.`, + }, + { + type: "markdown", + id: "intro-arch", + content: `## Architecture + +Three systems work together in a layered architecture:`, + }, + { + type: "mermaid", + id: "intro-arch-diagram", + title: "System Architecture", + content: `graph TD + User([User]) -->|chat message| CK[CopilotKit\nReact hooks + runtime] + CK -->|forwards to agent| DA[Deep Agent\nLangGraph + tools + skills] + DA -->|tool calls| Tools{Tools} + Tools -->|plan_visualization| DA + Tools -->|widgetRenderer| WR[Widget Renderer\nSandboxed iframe + Idiomorph] + Tools -->|pieChart / barChart| WR + WR -->|renders in browser| User + + style CK fill:#EDE9F5,stroke:#5B3FA0,color:#3E2B6F + style DA fill:#E3EFFC,stroke:#2663B3,color:#1A4680 + style WR fill:#E1F5EE,stroke:#0F6E56,color:#085041 + style User fill:#f7f6f3,stroke:#9c9a92,color:#1a1a1a + style Tools fill:#FAEEDA,stroke:#B8860B,color:#854F0B`, + }, + { + type: "code", + id: "intro-structure", + language: "bash", + filename: "Project Structure", + content: `apps/ +├── app/ # Next.js frontend +│ └── src/ +│ ├── components/ +│ │ └── generative-ui/ +│ │ └── widget-renderer.tsx # The iframe rendering engine +│ ├── hooks/ +│ │ └── use-generative-ui-examples.tsx # CopilotKit hook registrations +│ └── app/ +│ └── api/copilotkit/route.ts # CopilotKit runtime (connects to agent) +└── agent/ # LangGraph Python agent + ├── main.py # create_deep_agent + system prompt + └── src/ + ├── todos.py # AgentState schema + todo tools + ├── plan.py # Mandatory plan_visualization tool + ├── query.py # Data query tool + └── bounded_memory_saver.py # FIFO thread eviction`, + }, + { + type: "markdown", + id: "intro-flow", + content: `## The Visualization Flow + +Every visual response follows a **mandatory 4-step workflow**: + +1. **Acknowledge** — Agent replies with 1-2 sentences of context +2. **Plan** — Agent calls \`plan_visualization\` (approach, technology, key elements) +3. **Build** — Agent calls \`widgetRenderer\` / \`pieChart\` / \`barChart\` +4. **Narrate** — Agent adds 2-3 sentences walking through the result + +The plan step is never skipped — it gives the user a preview of what's coming and helps the agent organize its approach. + +Let's dive into each layer, starting with the **Widget Renderer**.`, + }, + ], +}; diff --git a/apps/notebook/src/content/chapters/02-agent-state.ts b/apps/notebook/src/content/chapters/02-agent-state.ts new file mode 100644 index 0000000..8a8e115 --- /dev/null +++ b/apps/notebook/src/content/chapters/02-agent-state.ts @@ -0,0 +1,338 @@ +import type { Chapter } from "@/lib/types"; + +export const widgetRenderer: Chapter = { + id: "widget-renderer", + title: "Widget Renderer", + description: + "The sandboxed iframe engine that renders agent-generated HTML, SVG, and 3D.", + icon: "🖼", + cells: [ + { + type: "markdown", + id: "wr-overview", + content: `# Widget Renderer + +The Widget Renderer is the core rendering engine. It takes arbitrary HTML from the agent and renders it in a **sandboxed iframe** with a full design system, streaming support, and a communication bridge. + +## What gets injected into the iframe + +The iframe isn't just raw HTML. Before any agent content is inserted, the iframe shell is assembled with 6 layers: + +1. **Theme CSS** — Light/dark mode variables (\`--color-text-primary\`, \`--color-background-secondary\`, etc.) +2. **SVG Classes** — Pre-built CSS classes for colored SVG elements (\`.c-purple\`, \`.c-teal\`, \`.c-blue\`) +3. **Form Styles** — Native-looking buttons, inputs, sliders, checkboxes with animations +4. **Bridge JS** — \`window.sendPrompt()\`, \`window.openLink()\`, auto-resize reporting +5. **Import Map** — ES module aliases for Three.js, GSAP, D3, Chart.js from esm.sh +6. **CSP Policy** — Restricts scripts to approved CDNs (cdnjs, esm.sh, jsdelivr, unpkg)`, + }, + { + type: "code", + id: "wr-bridge", + language: "typescript", + filename: "Bridge JS (injected into every iframe)", + content: `// The bridge gives widgets 3 capabilities: + +// 1. Send a new prompt to the agent +window.sendPrompt = (text: string) => { + window.parent.postMessage( + { type: "send-prompt", prompt: text }, + "*" + ); +}; + +// 2. Open external links (parent handles navigation) +window.openLink = (url: string) => { + window.parent.postMessage( + { type: "open-link", url }, + "*" + ); +}; + +// 3. Auto-resize: report content height to parent +function reportHeight() { + const clone = document.body.cloneNode(true); + clone.style.cssText = "position:absolute;left:-9999px;width:" + + document.body.clientWidth + "px;visibility:hidden;"; + document.documentElement.appendChild(clone); + const h = clone.scrollHeight; + clone.remove(); + window.parent.postMessage({ type: "widget-resize", height: h }, "*"); +} + +new ResizeObserver(reportHeight).observe(document.body);`, + }, + { + type: "markdown", + id: "wr-streaming", + content: `## Streaming with Idiomorph + +When the agent streams HTML, the widget renderer uses **Idiomorph** for efficient DOM diffing. Instead of replacing the entire iframe on each chunk, Idiomorph morphs the existing DOM — preserving interactive state, animations, and scroll position. + +The playground below demonstrates this: watch the HTML stream in character-by-character (like an LLM producing tokens), while the iframe updates progressively without flickering.`, + }, + { + type: "playground", + id: "wr-streaming-playground", + title: "Live: Streaming HTML into an iframe", + files: { + "/App.js": `import { useState, useRef, useEffect, useCallback } from "react"; + +// This is a real demo of how the widget renderer streams HTML. +// The HTML arrives token-by-token (like an LLM), and the iframe +// updates progressively — just like the real widget-renderer.tsx. + +const FULL_HTML = \` +

Agent Performance Dashboard

+
+

Key Metrics

+
Tool calls1,247
+
Avg latency340ms
+
Success rate99.2%
+
Active threads42
+
+
+

Tool Usage

+
widgets
78%
+
charts
52%
+
todos
35%
+
queries
20%
+
\`; + +export default function App() { + const [streamedHtml, setStreamedHtml] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const [charIndex, setCharIndex] = useState(0); + const [iframeHeight, setIframeHeight] = useState(60); + const iframeRef = useRef(null); + const intervalRef = useRef(null); + + // Listen for resize messages from iframe (just like the real bridge) + useEffect(() => { + const handler = (e) => { + if (e.data?.type === "widget-resize") { + setIframeHeight(Math.max(60, Math.min(800, e.data.height + 10))); + } + }; + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + // Update iframe content as HTML streams in + useEffect(() => { + if (!iframeRef.current || !streamedHtml) return; + const doc = iframeRef.current.contentDocument; + if (!doc) return; + doc.open(); + doc.write(\`\${streamedHtml} + + \`); + doc.close(); + }, [streamedHtml]); + + const startStream = useCallback(() => { + setStreamedHtml(""); + setCharIndex(0); + setIsStreaming(true); + setIframeHeight(60); + let idx = 0; + clearInterval(intervalRef.current); + intervalRef.current = setInterval(() => { + // Stream ~8 chars at a time (simulating token chunks) + idx = Math.min(idx + 8, FULL_HTML.length); + setStreamedHtml(FULL_HTML.slice(0, idx)); + setCharIndex(idx); + if (idx >= FULL_HTML.length) { + clearInterval(intervalRef.current); + setIsStreaming(false); + } + }, 16); + }, []); + + useEffect(() => () => clearInterval(intervalRef.current), []); + + const pct = FULL_HTML.length > 0 ? Math.round((charIndex / FULL_HTML.length) * 100) : 0; + + return ( +
+ {/* Streaming progress bar */} +
+ +
+
+
+ + {charIndex}/{FULL_HTML.length} + +
+ + {/* Live iframe preview — auto-resizes via bridge postMessage */} +
+ {!streamedHtml && !isStreaming ? ( +
+ Click "Stream HTML" to watch the widget render progressively +
+ ) : ( +