Ver código fonte

pdf (#124)

* no-ai

* pdf

* pdf example

* fixes

* ai gateway

* fixes

* fixes

* fixes

* shadcn

* 3 panes

* fixes

* fixes

* fixes

* fixes

* fix CI

* fix
Chris Tate 4 meses atrás
pai
commit
4c294417f4
60 arquivos alterados com 6928 adições e 7 exclusões
  1. 3 0
      .gitignore
  2. 37 0
      README.md
  3. 1 1
      apps/web/app/(main)/docs/ai-sdk/page.mdx
  4. 1 1
      examples/chat/next-env.d.ts
  5. 1 1
      examples/dashboard/next-env.d.ts
  6. 105 0
      examples/no-ai/app/globals.css
  7. 34 0
      examples/no-ai/app/layout.tsx
  8. 72 0
      examples/no-ai/app/page.tsx
  9. 12 0
      examples/no-ai/eslint.config.js
  10. 511 0
      examples/no-ai/lib/examples.ts
  11. 16 0
      examples/no-ai/lib/render/catalog.ts
  12. 29 0
      examples/no-ai/lib/render/registry.tsx
  13. 6 0
      examples/no-ai/next-env.d.ts
  14. 5 0
      examples/no-ai/next.config.ts
  15. 40 0
      examples/no-ai/package.json
  16. 5 0
      examples/no-ai/postcss.config.mjs
  17. 13 0
      examples/no-ai/tsconfig.json
  18. 5 0
      examples/react-pdf/.env.example
  19. 35 0
      examples/react-pdf/app/api/generate/route.ts
  20. 46 0
      examples/react-pdf/app/api/pdf/route.ts
  21. 130 0
      examples/react-pdf/app/globals.css
  22. 23 0
      examples/react-pdf/app/layout.tsx
  23. 615 0
      examples/react-pdf/app/page.tsx
  24. 23 0
      examples/react-pdf/components.json
  25. 64 0
      examples/react-pdf/components/ui/button.tsx
  26. 53 0
      examples/react-pdf/components/ui/resizable.tsx
  27. 58 0
      examples/react-pdf/components/ui/scroll-area.tsx
  28. 28 0
      examples/react-pdf/components/ui/separator.tsx
  29. 143 0
      examples/react-pdf/components/ui/sheet.tsx
  30. 91 0
      examples/react-pdf/components/ui/tabs.tsx
  31. 18 0
      examples/react-pdf/components/ui/textarea.tsx
  32. 8 0
      examples/react-pdf/lib/catalog.ts
  33. 1396 0
      examples/react-pdf/lib/examples.ts
  34. 6 0
      examples/react-pdf/lib/utils.ts
  35. 6 0
      examples/react-pdf/next-env.d.ts
  36. 7 0
      examples/react-pdf/next.config.ts
  37. 39 0
      examples/react-pdf/package.json
  38. 8 0
      examples/react-pdf/postcss.config.mjs
  39. 13 0
      examples/react-pdf/tsconfig.json
  40. 1 1
      examples/remotion/next-env.d.ts
  41. 1 1
      examples/stripe-app/api/next-env.d.ts
  42. 167 0
      packages/react-pdf/README.md
  43. 77 0
      packages/react-pdf/package.json
  44. 41 0
      packages/react-pdf/src/catalog-types.ts
  45. 294 0
      packages/react-pdf/src/catalog.ts
  46. 1 0
      packages/react-pdf/src/components/index.ts
  47. 459 0
      packages/react-pdf/src/components/standard.tsx
  48. 283 0
      packages/react-pdf/src/contexts/actions.tsx
  49. 26 0
      packages/react-pdf/src/contexts/repeat-scope.tsx
  50. 117 0
      packages/react-pdf/src/contexts/state.tsx
  51. 262 0
      packages/react-pdf/src/contexts/validation.tsx
  52. 64 0
      packages/react-pdf/src/contexts/visibility.tsx
  53. 92 0
      packages/react-pdf/src/index.ts
  54. 180 0
      packages/react-pdf/src/render.tsx
  55. 503 0
      packages/react-pdf/src/renderer.tsx
  56. 57 0
      packages/react-pdf/src/schema.ts
  57. 20 0
      packages/react-pdf/src/server.ts
  58. 9 0
      packages/react-pdf/tsconfig.json
  59. 10 0
      packages/react-pdf/tsup.config.ts
  60. 558 2
      pnpm-lock.yaml

+ 3 - 0
.gitignore

@@ -43,6 +43,9 @@ yarn-error.log*
 
 # opensrc - source code for packages
 opensrc/
+
+# json-studio (separate repo)
+json-studio/
 .env*.local
 
 # Stripe apps (generated from template + build artifacts)

+ 37 - 0
README.md

@@ -12,6 +12,8 @@ npm install @json-render/shadcn
 npm install @json-render/core @json-render/react-native
 # or for video
 npm install @json-render/core @json-render/remotion
+# or for PDF documents
+npm install @json-render/core @json-render/react-pdf
 ```
 
 ## Why json-render?
@@ -111,6 +113,7 @@ function Dashboard({ spec }) {
 | `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
 | `@json-render/react-native` | React Native renderer with standard mobile components |
 | `@json-render/remotion` | Remotion video renderer, timeline schema |
+| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
 
 ## Renderers
 
@@ -221,6 +224,40 @@ const spec = {
 />
 ```
 
+### React PDF (Documents)
+
+```typescript
+import { renderToBuffer } from "@json-render/react-pdf";
+
+const spec = {
+  root: "doc",
+  elements: {
+    doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
+    "page-1": {
+      type: "Page",
+      props: { size: "A4" },
+      children: ["heading-1", "table-1"],
+    },
+    "heading-1": {
+      type: "Heading",
+      props: { text: "Invoice #1234", level: "h1" },
+      children: [],
+    },
+    "table-1": {
+      type: "Table",
+      props: {
+        columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
+        rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
+      },
+      children: [],
+    },
+  },
+};
+
+// Render to buffer, stream, or file
+const buffer = await renderToBuffer(spec);
+```
+
 ## Features
 
 ### Streaming (SpecStream)

+ 1 - 1
apps/web/app/(main)/docs/ai-sdk/page.mdx

@@ -214,7 +214,7 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
       <tr>
         <td>System prompt</td>
         <td><code>catalog.prompt()</code></td>
-        <td><code>catalog.prompt({ mode: "chat" })</code></td>
+        <td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
       </tr>
       <tr>
         <td>Stream utility</td>

+ 1 - 1
examples/chat/next-env.d.ts

@@ -1,6 +1,6 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 1 - 1
examples/dashboard/next-env.d.ts

@@ -1,6 +1,6 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 105 - 0
examples/no-ai/app/globals.css

@@ -0,0 +1,105 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@source "../../../packages/shadcn/src/**/*.tsx";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
+  --radius-2xl: calc(var(--radius) + 8px);
+  --radius-3xl: calc(var(--radius) + 12px);
+  --radius-4xl: calc(var(--radius) + 16px);
+  --color-background: var(--background);
+  --color-foreground: var(--foreground);
+  --color-card: var(--card);
+  --color-card-foreground: var(--card-foreground);
+  --color-popover: var(--popover);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-primary: var(--primary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-secondary: var(--secondary);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-muted: var(--muted);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-accent: var(--accent);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-destructive: var(--destructive);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+  --color-chart-1: var(--chart-1);
+  --color-chart-2: var(--chart-2);
+  --color-chart-3: var(--chart-3);
+  --color-chart-4: var(--chart-4);
+  --color-chart-5: var(--chart-5);
+}
+
+:root {
+  --radius: 0.625rem;
+  --background: oklch(1 0 0);
+  --foreground: oklch(0.145 0 0);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.145 0 0);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.145 0 0);
+  --primary: oklch(0.205 0 0);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.97 0 0);
+  --secondary-foreground: oklch(0.205 0 0);
+  --muted: oklch(0.97 0 0);
+  --muted-foreground: oklch(0.556 0 0);
+  --accent: oklch(0.97 0 0);
+  --accent-foreground: oklch(0.205 0 0);
+  --destructive: oklch(0.577 0.245 27.325);
+  --border: oklch(0.922 0 0);
+  --input: oklch(0.922 0 0);
+  --ring: oklch(0.708 0 0);
+  --chart-1: oklch(0.646 0.222 41.116);
+  --chart-2: oklch(0.6 0.118 184.704);
+  --chart-3: oklch(0.398 0.07 227.392);
+  --chart-4: oklch(0.828 0.189 84.429);
+  --chart-5: oklch(0.769 0.188 70.08);
+}
+
+.dark {
+  --background: oklch(0.145 0 0);
+  --foreground: oklch(0.985 0 0);
+  --card: oklch(0.205 0 0);
+  --card-foreground: oklch(0.985 0 0);
+  --popover: oklch(0.205 0 0);
+  --popover-foreground: oklch(0.985 0 0);
+  --primary: oklch(0.922 0 0);
+  --primary-foreground: oklch(0.205 0 0);
+  --secondary: oklch(0.269 0 0);
+  --secondary-foreground: oklch(0.985 0 0);
+  --muted: oklch(0.269 0 0);
+  --muted-foreground: oklch(0.708 0 0);
+  --accent: oklch(0.269 0 0);
+  --accent-foreground: oklch(0.985 0 0);
+  --destructive: oklch(0.704 0.191 22.216);
+  --border: oklch(1 0 0 / 10%);
+  --input: oklch(1 0 0 / 15%);
+  --ring: oklch(0.556 0 0);
+  --chart-1: oklch(0.488 0.243 264.376);
+  --chart-2: oklch(0.696 0.17 162.48);
+  --chart-3: oklch(0.769 0.188 70.08);
+  --chart-4: oklch(0.627 0.265 303.9);
+  --chart-5: oklch(0.645 0.246 16.439);
+}
+
+@layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+  body {
+    @apply bg-background text-foreground;
+  }
+}
+
+button {
+  cursor: pointer;
+}

+ 34 - 0
examples/no-ai/app/layout.tsx

@@ -0,0 +1,34 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import "./globals.css";
+
+const geistSans = Geist({
+  variable: "--font-geist-sans",
+  subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+  variable: "--font-geist-mono",
+  subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+  title: "json-render No-AI Example",
+  description: "Static JSON specs rendered with json-render -- no AI required",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body
+        className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
+      >
+        {children}
+      </body>
+    </html>
+  );
+}

+ 72 - 0
examples/no-ai/app/page.tsx

@@ -0,0 +1,72 @@
+"use client";
+
+import { useState, useEffect, useCallback, type ReactNode } from "react";
+import ConfettiExplosion from "react-confetti-explosion";
+import {
+  Renderer,
+  StateProvider,
+  VisibilityProvider,
+  ActionProvider,
+  ValidationProvider,
+} from "@json-render/react";
+import type { Spec } from "@json-render/core";
+import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
+import { examples } from "@/lib/examples";
+
+function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
+  return (
+    <StateProvider initialState={spec.state ?? {}}>
+      <VisibilityProvider>
+        <ActionProvider handlers={actionHandlers}>
+          <ValidationProvider>
+            <Renderer spec={spec} registry={registry} />
+          </ValidationProvider>
+        </ActionProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}
+
+export default function Page() {
+  const [selectedIndex] = useState(0);
+  const selected = examples[selectedIndex]!;
+  const [confettiKey, setConfettiKey] = useState(0);
+  const [confettiActive, setConfettiActive] = useState(false);
+
+  const fireConfetti = useCallback(() => {
+    setConfettiKey((k) => k + 1);
+    setConfettiActive(true);
+  }, []);
+
+  useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
+
+  return (
+    <div className="h-screen flex items-center justify-center bg-muted/30">
+      <div
+        className="relative bg-background border rounded-lg shadow-sm"
+        style={{ width: 960, height: 1080 }}
+      >
+        {confettiActive && (
+          <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+            <ConfettiExplosion
+              key={confettiKey}
+              portal={false}
+              force={0.8}
+              duration={3500}
+              particleCount={400}
+              particleSize={8}
+              colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
+              width={1600}
+              height="200vh"
+              zIndex={1}
+              onComplete={() => setConfettiActive(false)}
+            />
+          </div>
+        )}
+        <div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
+          <SpecRenderer key={selectedIndex} spec={selected.spec} />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 12 - 0
examples/no-ai/eslint.config.js

@@ -0,0 +1,12 @@
+import { nextJsConfig } from "@repo/eslint-config/next-js";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+  ...nextJsConfig,
+  {
+    rules: {
+      "react/prop-types": "off",
+      "react/no-unknown-property": ["error", { ignore: ["jsx"] }],
+    },
+  },
+];

+ 511 - 0
examples/no-ai/lib/examples.ts

@@ -0,0 +1,511 @@
+import type { Spec } from "@json-render/core";
+
+export interface Example {
+  name: string;
+  description: string;
+  spec: Spec;
+}
+
+export const examples: Example[] = [
+  {
+    name: "Confetti",
+    description: "A button that fires confetti when clicked",
+    spec: {
+      root: "btn",
+      elements: {
+        btn: {
+          type: "Button",
+          props: { label: "Click me", variant: "primary" },
+          on: { press: { action: "confetti" } },
+        },
+      },
+    },
+  },
+
+  {
+    name: "User Profile Card",
+    description: "A card with user info, badges, and a progress bar",
+    spec: {
+      root: "card",
+      elements: {
+        card: {
+          type: "Card",
+          props: {
+            title: "User Profile",
+            description: null,
+            maxWidth: "md",
+            centered: null,
+          },
+          children: ["stack"],
+        },
+        stack: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "md",
+            align: null,
+            justify: null,
+          },
+          children: ["heading", "text", "badges", "sep", "progress"],
+        },
+        heading: {
+          type: "Heading",
+          props: { text: "Jane Cooper", level: "h2" },
+        },
+        text: {
+          type: "Text",
+          props: {
+            text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
+            variant: "muted",
+          },
+        },
+        badges: {
+          type: "Stack",
+          props: {
+            direction: "horizontal",
+            gap: "sm",
+            align: null,
+            justify: null,
+          },
+          children: ["b1", "b2", "b3"],
+        },
+        b1: {
+          type: "Badge",
+          props: { text: "TypeScript", variant: "default" },
+        },
+        b2: { type: "Badge", props: { text: "React", variant: "secondary" } },
+        b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
+        sep: { type: "Separator", props: { orientation: null } },
+        progress: {
+          type: "Progress",
+          props: { value: 72, max: 100, label: "Profile completion" },
+        },
+      },
+    },
+  },
+
+  {
+    name: "Settings Form",
+    description: "A form with inputs, selects, switches, and checkboxes",
+    spec: {
+      root: "card",
+      state: {
+        name: "Ada Lovelace",
+        email: "ada@example.com",
+        role: "Engineer",
+        notifications: true,
+        darkMode: false,
+      },
+      elements: {
+        card: {
+          type: "Card",
+          props: {
+            title: "Account Settings",
+            description: "Manage your preferences",
+            maxWidth: "md",
+            centered: null,
+          },
+          children: ["form"],
+        },
+        form: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "md",
+            align: null,
+            justify: null,
+          },
+          children: [
+            "nameInput",
+            "emailInput",
+            "roleSelect",
+            "sep",
+            "notifSwitch",
+            "darkSwitch",
+            "sep2",
+            "actions",
+          ],
+        },
+        nameInput: {
+          type: "Input",
+          props: {
+            label: "Full Name",
+            name: "name",
+            type: "text",
+            placeholder: "Your name",
+            value: { $bindState: "/name" },
+            checks: null,
+          },
+        },
+        emailInput: {
+          type: "Input",
+          props: {
+            label: "Email",
+            name: "email",
+            type: "email",
+            placeholder: "you@example.com",
+            value: { $bindState: "/email" },
+            checks: null,
+          },
+        },
+        roleSelect: {
+          type: "Select",
+          props: {
+            label: "Role",
+            name: "role",
+            options: [
+              "Engineer",
+              "Designer",
+              "Product Manager",
+              "Data Scientist",
+            ],
+            placeholder: "Choose a role",
+            value: { $bindState: "/role" },
+            checks: null,
+          },
+        },
+        sep: { type: "Separator", props: { orientation: null } },
+        notifSwitch: {
+          type: "Switch",
+          props: {
+            label: "Email notifications",
+            name: "notifications",
+            checked: { $bindState: "/notifications" },
+          },
+        },
+        darkSwitch: {
+          type: "Switch",
+          props: {
+            label: "Dark mode",
+            name: "darkMode",
+            checked: { $bindState: "/darkMode" },
+          },
+        },
+        sep2: { type: "Separator", props: { orientation: null } },
+        actions: {
+          type: "Stack",
+          props: {
+            direction: "horizontal",
+            gap: "sm",
+            align: null,
+            justify: "end",
+          },
+          children: ["cancelBtn", "saveBtn"],
+        },
+        cancelBtn: {
+          type: "Button",
+          props: { label: "Cancel", variant: "secondary", disabled: null },
+        },
+        saveBtn: {
+          type: "Button",
+          props: { label: "Save Changes", variant: "primary", disabled: null },
+        },
+      },
+    },
+  },
+
+  {
+    name: "Pricing Table",
+    description: "A grid of pricing cards with feature lists",
+    spec: {
+      root: "outer",
+      elements: {
+        outer: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "lg",
+            align: null,
+            justify: null,
+          },
+          children: ["header", "grid"],
+        },
+        header: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "sm",
+            align: "center",
+            justify: null,
+          },
+          children: ["title", "subtitle"],
+        },
+        title: {
+          type: "Heading",
+          props: { text: "Simple, transparent pricing", level: "h1" },
+        },
+        subtitle: {
+          type: "Text",
+          props: {
+            text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
+            variant: "muted",
+          },
+        },
+        grid: {
+          type: "Grid",
+          props: { columns: 3, gap: "md" },
+          children: ["free", "pro", "enterprise"],
+        },
+        free: {
+          type: "Card",
+          props: {
+            title: "Free",
+            description: "$0/month",
+            maxWidth: null,
+            centered: null,
+          },
+          children: ["freeContent"],
+        },
+        freeContent: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "sm",
+            align: null,
+            justify: null,
+          },
+          children: ["f1", "f2", "f3", "freeBtn"],
+        },
+        f1: {
+          type: "Text",
+          props: { text: "Up to 3 projects", variant: "body" },
+        },
+        f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
+        f3: {
+          type: "Text",
+          props: { text: "Community support", variant: "body" },
+        },
+        freeBtn: {
+          type: "Button",
+          props: { label: "Get Started", variant: "secondary", disabled: null },
+        },
+        pro: {
+          type: "Card",
+          props: {
+            title: "Pro",
+            description: "$19/month",
+            maxWidth: null,
+            centered: null,
+          },
+          children: ["proContent"],
+        },
+        proContent: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "sm",
+            align: null,
+            justify: null,
+          },
+          children: ["p1", "p2", "p3", "p4", "proBtn"],
+        },
+        p1: {
+          type: "Text",
+          props: { text: "Unlimited projects", variant: "body" },
+        },
+        p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
+        p3: {
+          type: "Text",
+          props: { text: "Priority support", variant: "body" },
+        },
+        p4: {
+          type: "Text",
+          props: { text: "Custom domains", variant: "body" },
+        },
+        proBtn: {
+          type: "Button",
+          props: {
+            label: "Upgrade to Pro",
+            variant: "primary",
+            disabled: null,
+          },
+        },
+        enterprise: {
+          type: "Card",
+          props: {
+            title: "Enterprise",
+            description: "Custom pricing",
+            maxWidth: null,
+            centered: null,
+          },
+          children: ["entContent"],
+        },
+        entContent: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "sm",
+            align: null,
+            justify: null,
+          },
+          children: ["e1", "e2", "e3", "e4", "entBtn"],
+        },
+        e1: {
+          type: "Text",
+          props: { text: "Everything in Pro", variant: "body" },
+        },
+        e2: {
+          type: "Text",
+          props: { text: "Unlimited storage", variant: "body" },
+        },
+        e3: {
+          type: "Text",
+          props: { text: "Dedicated support", variant: "body" },
+        },
+        e4: {
+          type: "Text",
+          props: { text: "SLA guarantees", variant: "body" },
+        },
+        entBtn: {
+          type: "Button",
+          props: {
+            label: "Contact Sales",
+            variant: "secondary",
+            disabled: null,
+          },
+        },
+      },
+    },
+  },
+
+  {
+    name: "Status Dashboard",
+    description: "System status with alerts, a table, and an accordion",
+    spec: {
+      root: "root",
+      elements: {
+        root: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "lg",
+            align: null,
+            justify: null,
+          },
+          children: [
+            "heading",
+            "alertOk",
+            "alertWarn",
+            "sep",
+            "table",
+            "sep2",
+            "accordion",
+          ],
+        },
+        heading: {
+          type: "Heading",
+          props: { text: "System Status", level: "h1" },
+        },
+        alertOk: {
+          type: "Alert",
+          props: {
+            title: "API",
+            message: "All systems operational -- 99.98% uptime this month",
+            type: "success",
+          },
+        },
+        alertWarn: {
+          type: "Alert",
+          props: {
+            title: "Database",
+            message: "Elevated latency detected in us-east-1 region",
+            type: "warning",
+          },
+        },
+        sep: { type: "Separator", props: { orientation: null } },
+        table: {
+          type: "Table",
+          props: {
+            columns: ["Service", "Status", "Latency", "Uptime"],
+            rows: [
+              ["API Gateway", "Operational", "12ms", "99.99%"],
+              ["Auth Service", "Operational", "8ms", "99.98%"],
+              ["Database", "Degraded", "145ms", "99.85%"],
+              ["CDN", "Operational", "3ms", "100%"],
+              ["Workers", "Operational", "22ms", "99.97%"],
+            ],
+            caption: "Last updated 2 minutes ago",
+          },
+        },
+        sep2: { type: "Separator", props: { orientation: null } },
+        accordion: {
+          type: "Accordion",
+          props: {
+            items: [
+              {
+                title: "What does 'Degraded' mean?",
+                content:
+                  "The service is still operational but experiencing slower response times than normal. We are actively investigating.",
+              },
+              {
+                title: "How is uptime calculated?",
+                content:
+                  "Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
+              },
+              {
+                title: "How do I subscribe to updates?",
+                content:
+                  "You can subscribe to incident notifications via email or webhook on the Settings page.",
+              },
+            ],
+            type: "single",
+          },
+        },
+      },
+    },
+  },
+
+  {
+    name: "Feature Comparison",
+    description: "Tables inside an accordion for comparing plan tiers",
+    spec: {
+      root: "root",
+      elements: {
+        root: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "lg",
+            align: null,
+            justify: null,
+          },
+          children: ["heading", "subtitle", "accordion"],
+        },
+        heading: {
+          type: "Heading",
+          props: { text: "Feature Comparison", level: "h1" },
+        },
+        subtitle: {
+          type: "Text",
+          props: {
+            text: "Expand each section to see how plans compare.",
+            variant: "muted",
+          },
+        },
+        accordion: {
+          type: "Accordion",
+          props: {
+            items: [
+              {
+                title: "Projects & Team",
+                content:
+                  "Free: 3 projects, 1 member | Pro: Unlimited projects, 10 members | Enterprise: Unlimited everything",
+              },
+              {
+                title: "Storage & Bandwidth",
+                content:
+                  "Free: 1 GB storage, 10 GB/mo bandwidth | Pro: 50 GB storage, 500 GB/mo | Enterprise: Unlimited",
+              },
+              {
+                title: "Support & SLA",
+                content:
+                  "Free: Forum, no SLA | Pro: Email + Chat, 99.9% SLA | Enterprise: Dedicated Slack, 99.99% SLA",
+              },
+            ],
+            type: "single",
+          },
+        },
+      },
+    },
+  },
+];

+ 16 - 0
examples/no-ai/lib/render/catalog.ts

@@ -0,0 +1,16 @@
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
+import { z } from "zod";
+
+export const catalog = defineCatalog(schema, {
+  components: {
+    ...shadcnComponentDefinitions,
+  },
+  actions: {
+    confetti: {
+      params: z.object({}),
+      description: "Fire confetti",
+    },
+  },
+});

+ 29 - 0
examples/no-ai/lib/render/registry.tsx

@@ -0,0 +1,29 @@
+"use client";
+
+import { defineRegistry } from "@json-render/react";
+import { shadcnComponents } from "@json-render/shadcn";
+import { catalog } from "./catalog";
+
+let confettiListener: (() => void) | null = null;
+
+export function onConfetti(cb: () => void) {
+  confettiListener = cb;
+  return () => {
+    confettiListener = null;
+  };
+}
+
+export const { registry } = defineRegistry(catalog, {
+  components: {
+    ...shadcnComponents,
+  },
+  actions: {
+    confetti: async () => {
+      confettiListener?.();
+    },
+  },
+});
+
+export const actionHandlers: Record<string, () => void> = {
+  confetti: () => confettiListener?.(),
+};

+ 6 - 0
examples/no-ai/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 5 - 0
examples/no-ai/next.config.ts

@@ -0,0 +1,5 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {};
+
+export default nextConfig;

+ 40 - 0
examples/no-ai/package.json

@@ -0,0 +1,40 @@
+{
+  "name": "example-no-ai",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --turbopack --port 3003",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "@json-render/shadcn": "workspace:*",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "lucide-react": "^0.563.0",
+    "next": "16.1.6",
+    "radix-ui": "^1.4.3",
+    "react": "19.2.4",
+    "react-dom": "19.2.4",
+    "react-confetti-explosion": "^3.0.3",
+    "tailwind-merge": "^3.4.0",
+    "zod": "4.3.5"
+  },
+  "devDependencies": {
+    "@repo/eslint-config": "workspace:*",
+    "@tailwindcss/postcss": "^4.1.18",
+    "@types/node": "^22.10.0",
+    "@types/react": "19.2.3",
+    "@types/react-dom": "19.2.3",
+    "eslint": "^9.39.1",
+    "postcss": "^8.5.6",
+    "tailwindcss": "^4.1.18",
+    "tw-animate-css": "^1.4.0",
+    "typescript": "^5.7.2"
+  }
+}

+ 5 - 0
examples/no-ai/postcss.config.mjs

@@ -0,0 +1,5 @@
+export default {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};

+ 13 - 0
examples/no-ai/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "declaration": false,
+    "declarationMap": false,
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 5 - 0
examples/react-pdf/.env.example

@@ -0,0 +1,5 @@
+# Required: AI Gateway API key
+AI_GATEWAY_API_KEY=
+
+# Optional: override the default model (default: anthropic/claude-haiku-4.5)
+# AI_GATEWAY_MODEL=openai/gpt-4o-mini

+ 35 - 0
examples/react-pdf/app/api/generate/route.ts

@@ -0,0 +1,35 @@
+import { streamText } from "ai";
+import { gateway } from "@ai-sdk/gateway";
+import { buildUserPrompt, type Spec } from "@json-render/core";
+import { pdfCatalog } from "@/lib/catalog";
+
+export const maxDuration = 60;
+
+const SYSTEM_PROMPT = pdfCatalog.prompt();
+
+const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
+
+export async function POST(req: Request) {
+  const { prompt, startingSpec } = (await req.json()) as {
+    prompt: string;
+    startingSpec?: Spec | null;
+  };
+
+  if (!prompt || typeof prompt !== "string") {
+    return Response.json({ error: "prompt is required" }, { status: 400 });
+  }
+
+  const userPrompt = buildUserPrompt({
+    prompt,
+    currentSpec: startingSpec,
+  });
+
+  const result = streamText({
+    model: gateway(process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL),
+    system: SYSTEM_PROMPT,
+    prompt: userPrompt,
+    temperature: 0.7,
+  });
+
+  return result.toTextStreamResponse();
+}

+ 46 - 0
examples/react-pdf/app/api/pdf/route.ts

@@ -0,0 +1,46 @@
+import { renderToBuffer } from "@json-render/react-pdf/render";
+import { examples } from "@/lib/examples";
+import type { Spec } from "@json-render/core";
+
+export async function GET(req: Request) {
+  const { searchParams } = new URL(req.url);
+  const name = searchParams.get("name") ?? "invoice";
+  const download = searchParams.get("download") === "1";
+
+  const example = examples.find((e) => e.name === name);
+  if (!example) {
+    return new Response("Example not found", { status: 404 });
+  }
+
+  return pdfResponse(example.spec, name, download);
+}
+
+export async function POST(req: Request) {
+  const { spec, download, filename } = (await req.json()) as {
+    spec: Spec;
+    download?: boolean;
+    filename?: string;
+  };
+
+  if (!spec || !spec.root || !spec.elements) {
+    return new Response("Invalid spec", { status: 400 });
+  }
+
+  return pdfResponse(spec, filename ?? "document", download ?? false);
+}
+
+async function pdfResponse(spec: Spec, name: string, download: boolean) {
+  const buffer = await renderToBuffer(spec);
+
+  const disposition = download
+    ? `attachment; filename="${name}.pdf"`
+    : `inline; filename="${name}.pdf"`;
+
+  return new Response(buffer as unknown as ArrayBuffer, {
+    headers: {
+      "Content-Type": "application/pdf",
+      "Content-Disposition": disposition,
+      "Cache-Control": "no-store",
+    },
+  });
+}

+ 130 - 0
examples/react-pdf/app/globals.css

@@ -0,0 +1,130 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+    --radius-sm: calc(var(--radius) - 4px);
+    --radius-md: calc(var(--radius) - 2px);
+    --radius-lg: var(--radius);
+    --radius-xl: calc(var(--radius) + 4px);
+    --radius-2xl: calc(var(--radius) + 8px);
+    --radius-3xl: calc(var(--radius) + 12px);
+    --radius-4xl: calc(var(--radius) + 16px);
+    --color-background: var(--background);
+    --color-foreground: var(--foreground);
+    --color-card: var(--card);
+    --color-card-foreground: var(--card-foreground);
+    --color-popover: var(--popover);
+    --color-popover-foreground: var(--popover-foreground);
+    --color-primary: var(--primary);
+    --color-primary-foreground: var(--primary-foreground);
+    --color-secondary: var(--secondary);
+    --color-secondary-foreground: var(--secondary-foreground);
+    --color-muted: var(--muted);
+    --color-muted-foreground: var(--muted-foreground);
+    --color-accent: var(--accent);
+    --color-accent-foreground: var(--accent-foreground);
+    --color-destructive: var(--destructive);
+    --color-border: var(--border);
+    --color-input: var(--input);
+    --color-ring: var(--ring);
+    --color-chart-1: var(--chart-1);
+    --color-chart-2: var(--chart-2);
+    --color-chart-3: var(--chart-3);
+    --color-chart-4: var(--chart-4);
+    --color-chart-5: var(--chart-5);
+    --color-sidebar: var(--sidebar);
+    --color-sidebar-foreground: var(--sidebar-foreground);
+    --color-sidebar-primary: var(--sidebar-primary);
+    --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+    --color-sidebar-accent: var(--sidebar-accent);
+    --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+    --color-sidebar-border: var(--sidebar-border);
+    --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+    --radius: 0.625rem;
+    --background: oklch(1 0 0);
+    --foreground: oklch(0.145 0 0);
+    --card: oklch(1 0 0);
+    --card-foreground: oklch(0.145 0 0);
+    --popover: oklch(1 0 0);
+    --popover-foreground: oklch(0.145 0 0);
+    --primary: oklch(0.205 0 0);
+    --primary-foreground: oklch(0.985 0 0);
+    --secondary: oklch(0.97 0 0);
+    --secondary-foreground: oklch(0.205 0 0);
+    --muted: oklch(0.97 0 0);
+    --muted-foreground: oklch(0.556 0 0);
+    --accent: oklch(0.97 0 0);
+    --accent-foreground: oklch(0.205 0 0);
+    --destructive: oklch(0.577 0.245 27.325);
+    --border: oklch(0.922 0 0);
+    --input: oklch(0.922 0 0);
+    --ring: oklch(0.708 0 0);
+    --chart-1: oklch(0.646 0.222 41.116);
+    --chart-2: oklch(0.6 0.118 184.704);
+    --chart-3: oklch(0.398 0.07 227.392);
+    --chart-4: oklch(0.828 0.189 84.429);
+    --chart-5: oklch(0.769 0.188 70.08);
+    --sidebar: oklch(0.985 0 0);
+    --sidebar-foreground: oklch(0.145 0 0);
+    --sidebar-primary: oklch(0.205 0 0);
+    --sidebar-primary-foreground: oklch(0.985 0 0);
+    --sidebar-accent: oklch(0.97 0 0);
+    --sidebar-accent-foreground: oklch(0.205 0 0);
+    --sidebar-border: oklch(0.922 0 0);
+    --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+    --background: oklch(0.145 0 0);
+    --foreground: oklch(0.985 0 0);
+    --card: oklch(0.205 0 0);
+    --card-foreground: oklch(0.985 0 0);
+    --popover: oklch(0.205 0 0);
+    --popover-foreground: oklch(0.985 0 0);
+    --primary: oklch(0.922 0 0);
+    --primary-foreground: oklch(0.205 0 0);
+    --secondary: oklch(0.269 0 0);
+    --secondary-foreground: oklch(0.985 0 0);
+    --muted: oklch(0.269 0 0);
+    --muted-foreground: oklch(0.708 0 0);
+    --accent: oklch(0.269 0 0);
+    --accent-foreground: oklch(0.985 0 0);
+    --destructive: oklch(0.704 0.191 22.216);
+    --border: oklch(1 0 0 / 10%);
+    --input: oklch(1 0 0 / 15%);
+    --ring: oklch(0.556 0 0);
+    --chart-1: oklch(0.488 0.243 264.376);
+    --chart-2: oklch(0.696 0.17 162.48);
+    --chart-3: oklch(0.769 0.188 70.08);
+    --chart-4: oklch(0.627 0.265 303.9);
+    --chart-5: oklch(0.645 0.246 16.439);
+    --sidebar: oklch(0.205 0 0);
+    --sidebar-foreground: oklch(0.985 0 0);
+    --sidebar-primary: oklch(0.488 0.243 264.376);
+    --sidebar-primary-foreground: oklch(0.985 0 0);
+    --sidebar-accent: oklch(0.269 0 0);
+    --sidebar-accent-foreground: oklch(0.985 0 0);
+    --sidebar-border: oklch(1 0 0 / 10%);
+    --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+  body {
+    @apply bg-background text-foreground;
+  }
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}

+ 23 - 0
examples/react-pdf/app/layout.tsx

@@ -0,0 +1,23 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"] });
+
+export const metadata: Metadata = {
+  title: "json-render React PDF Example",
+  description:
+    "Generate PDF documents from JSON specs with @json-render/react-pdf",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body className={inter.className}>{children}</body>
+    </html>
+  );
+}

+ 615 - 0
examples/react-pdf/app/page.tsx

@@ -0,0 +1,615 @@
+"use client";
+
+import { useState, useCallback, useRef, useEffect } from "react";
+import { examples } from "@/lib/examples";
+import { createSpecStreamCompiler } from "@json-render/core";
+import type { Spec } from "@json-render/core";
+import { cn } from "@/lib/utils";
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
+import {
+  ResizablePanelGroup,
+  ResizablePanel,
+  ResizableHandle,
+} from "@/components/ui/resizable";
+import { FileText, Download, Loader2, ArrowRight, Square } from "lucide-react";
+
+type Mode = "scratch" | "example";
+type MobileView = "json" | "preview";
+
+interface Selection {
+  mode: Mode;
+  exampleName?: string;
+}
+
+const PDF_REFRESH_INTERVAL_MS = 2000;
+
+function CopyButton({ text }: { text: string }) {
+  const [copied, setCopied] = useState(false);
+
+  return (
+    <button
+      onClick={() => {
+        navigator.clipboard.writeText(text);
+        setCopied(true);
+        setTimeout(() => setCopied(false), 1500);
+      }}
+      className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
+    >
+      {copied ? "copied" : "copy"}
+    </button>
+  );
+}
+
+function isRenderableSpec(spec: Spec | null): spec is Spec {
+  if (!spec?.root || !spec.elements) return false;
+  const root = spec.elements[spec.root];
+  if (!root) return false;
+  if (root.type !== "Document" || !root.children?.length) return false;
+  const firstChild = spec.elements[root.children[0]!];
+  return firstChild?.type === "Page";
+}
+
+export default function Page() {
+  const [selection, setSelection] = useState<Selection>({
+    mode: "example",
+    exampleName: examples[0]!.name,
+  });
+  const [prompt, setPrompt] = useState("");
+  const [generating, setGenerating] = useState(false);
+  const [generatedSpec, setGeneratedSpec] = useState<Spec | null>(null);
+  const [pdfUrl, setPdfUrl] = useState<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [mobileView, setMobileView] = useState<MobileView>("preview");
+  const [examplesSheetOpen, setExamplesSheetOpen] = useState(false);
+  const [refreshing, setRefreshing] = useState(false);
+  const pdfUrlRef = useRef<string | null>(null);
+  const inputRef = useRef<HTMLTextAreaElement>(null);
+  const mobileInputRef = useRef<HTMLTextAreaElement>(null);
+  const abortRef = useRef<AbortController | null>(null);
+  const codeScrollRef = useRef<HTMLDivElement>(null);
+  const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!generating) return;
+    codeScrollRef.current?.scrollTo({
+      top: codeScrollRef.current.scrollHeight,
+    });
+    mobileCodeScrollRef.current?.scrollTo({
+      top: mobileCodeScrollRef.current.scrollHeight,
+    });
+  }, [generating, generatedSpec]);
+
+  const currentExample =
+    selection.mode === "example"
+      ? examples.find((e) => e.name === selection.exampleName)
+      : null;
+
+  const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
+
+  const examplePdfUrl =
+    selection.mode === "example" && !generatedSpec
+      ? `/api/pdf?name=${selection.exampleName}`
+      : null;
+
+  const displayPdfUrl = pdfUrl ?? examplePdfUrl;
+
+  useEffect(() => {
+    inputRef.current?.focus();
+  }, [selection.mode, selection.exampleName]);
+
+  const fetchPdfBlob = useCallback(async (spec: Spec, signal?: AbortSignal) => {
+    const res = await fetch("/api/pdf", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ spec }),
+      signal,
+    });
+    if (!res.ok) throw new Error("Failed to generate PDF");
+    const blob = await res.blob();
+    const url = URL.createObjectURL(blob);
+
+    const prev = pdfUrlRef.current;
+    pdfUrlRef.current = url;
+    setPdfUrl(url);
+
+    if (prev) URL.revokeObjectURL(prev);
+  }, []);
+
+  // Progressive PDF refresh during generation
+  const lastRefreshSpec = useRef<string>("");
+  const generatedSpecRef = useRef<Spec | null>(null);
+  generatedSpecRef.current = generatedSpec;
+
+  useEffect(() => {
+    if (!generating) return;
+
+    const interval = setInterval(() => {
+      const spec = generatedSpecRef.current;
+      if (!spec) return;
+
+      const specKey = JSON.stringify(spec);
+      if (specKey === lastRefreshSpec.current) return;
+      if (!isRenderableSpec(spec)) return;
+
+      lastRefreshSpec.current = specKey;
+      setRefreshing(true);
+      fetchPdfBlob(spec)
+        .catch(() => {})
+        .finally(() => setRefreshing(false));
+    }, PDF_REFRESH_INTERVAL_MS);
+
+    return () => clearInterval(interval);
+  }, [generating, fetchPdfBlob]);
+
+  const handleGenerate = useCallback(async () => {
+    if (!prompt.trim()) return;
+
+    abortRef.current?.abort();
+    const controller = new AbortController();
+    abortRef.current = controller;
+
+    setGenerating(true);
+    setError(null);
+    lastRefreshSpec.current = "";
+
+    try {
+      const startingSpec =
+        selection.mode === "example" && currentExample
+          ? currentExample.spec
+          : null;
+
+      const res = await fetch("/api/generate", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ prompt: prompt.trim(), startingSpec }),
+        signal: controller.signal,
+      });
+      if (!res.ok) throw new Error("Generation failed");
+
+      const reader = res.body?.getReader();
+      if (!reader) throw new Error("No response body");
+
+      const decoder = new TextDecoder();
+      const compiler = createSpecStreamCompiler<Spec>(
+        startingSpec ? { ...startingSpec } : {},
+      );
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+        const chunk = decoder.decode(value, { stream: true });
+        const { result, newPatches } = compiler.push(chunk);
+        if (newPatches.length > 0) setGeneratedSpec(result);
+      }
+
+      const finalSpec = compiler.getResult();
+      setGeneratedSpec(finalSpec);
+      setGenerating(false);
+
+      await fetchPdfBlob(finalSpec);
+    } catch (e) {
+      if (controller.signal.aborted) return;
+      setError(e instanceof Error ? e.message : "Something went wrong");
+      setGenerating(false);
+    }
+  }, [prompt, selection, currentExample, fetchPdfBlob]);
+
+  const handleStop = useCallback(() => {
+    abortRef.current?.abort();
+    setGenerating(false);
+
+    if (isRenderableSpec(generatedSpec)) {
+      fetchPdfBlob(generatedSpec).catch(() => {});
+    }
+  }, [generatedSpec, fetchPdfBlob]);
+
+  const select = (next: Selection) => {
+    abortRef.current?.abort();
+    setSelection(next);
+    setGeneratedSpec(null);
+    setPdfUrl(null);
+    setError(null);
+    setPrompt("");
+    setGenerating(false);
+    setExamplesSheetOpen(false);
+  };
+
+  const handleDownload = async () => {
+    if (!activeSpec) return;
+    if (generatedSpec) {
+      const res = await fetch("/api/pdf", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ spec: generatedSpec, download: true }),
+      });
+      const blob = await res.blob();
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement("a");
+      a.href = url;
+      a.download = "document.pdf";
+      a.click();
+      URL.revokeObjectURL(url);
+    } else if (selection.mode === "example") {
+      window.open(
+        `/api/pdf?name=${selection.exampleName}&download=1`,
+        "_blank",
+      );
+    }
+  };
+
+  const handleKeyDown = useCallback(
+    (e: React.KeyboardEvent) => {
+      if (e.key === "Enter" && !e.shiftKey) {
+        e.preventDefault();
+        handleGenerate();
+      }
+    },
+    [handleGenerate],
+  );
+
+  const jsonCode = activeSpec
+    ? JSON.stringify(activeSpec, null, 2)
+    : "// select an example or generate a PDF";
+
+  // ---------------------------------------------------------------------------
+  // Pane: Chat / Examples
+  // ---------------------------------------------------------------------------
+  const chatPane = (
+    <div className="h-full flex flex-col">
+      <div className="border-b border-border px-3 h-9 flex items-center gap-2">
+        <FileText className="h-3.5 w-3.5 text-muted-foreground" />
+        <span className="text-xs font-mono text-muted-foreground">
+          json-render / react-pdf
+        </span>
+      </div>
+
+      <ScrollArea className="flex-1">
+        <div className="p-2 space-y-1">
+          <p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
+            start
+          </p>
+          <button
+            onClick={() => select({ mode: "scratch" })}
+            className={cn(
+              "w-full text-left px-3 py-2 rounded text-sm transition-colors",
+              selection.mode === "scratch"
+                ? "bg-muted text-foreground"
+                : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+            )}
+          >
+            <span className="font-medium">From scratch</span>
+          </button>
+
+          <p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
+            examples
+          </p>
+          {examples.map((ex) => (
+            <button
+              key={ex.name}
+              onClick={() => select({ mode: "example", exampleName: ex.name })}
+              className={cn(
+                "w-full text-left px-3 py-2 rounded text-sm transition-colors",
+                selection.mode === "example" &&
+                  selection.exampleName === ex.name
+                  ? "bg-muted text-foreground"
+                  : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+              )}
+            >
+              <span className="font-medium">{ex.label}</span>
+              <p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
+                {ex.description}
+              </p>
+            </button>
+          ))}
+        </div>
+      </ScrollArea>
+
+      <div
+        className="border-t border-border p-3 cursor-text"
+        onMouseDown={(e) => {
+          const target = e.target as HTMLElement;
+          if (!target.closest("button") && target.tagName !== "TEXTAREA") {
+            e.preventDefault();
+            inputRef.current?.focus();
+          }
+        }}
+      >
+        {error && (
+          <div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
+            {error}
+          </div>
+        )}
+        <textarea
+          ref={inputRef}
+          value={prompt}
+          onChange={(e) => setPrompt(e.target.value)}
+          onKeyDown={handleKeyDown}
+          placeholder={
+            selection.mode === "scratch"
+              ? "Describe the PDF you want..."
+              : `Modify the ${currentExample?.label ?? "example"}...`
+          }
+          className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
+          rows={2}
+          autoFocus
+        />
+        <div className="flex justify-between items-center mt-2">
+          <span className="text-[11px] text-muted-foreground">
+            {selection.mode === "example" && currentExample
+              ? currentExample.label
+              : "scratch"}
+          </span>
+          {generating ? (
+            <button
+              onClick={handleStop}
+              className="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"
+            >
+              <Square className="h-3 w-3" fill="currentColor" />
+            </button>
+          ) : (
+            <button
+              onClick={handleGenerate}
+              disabled={!prompt.trim()}
+              className="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="Generate"
+            >
+              <ArrowRight className="h-3.5 w-3.5" />
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+
+  // ---------------------------------------------------------------------------
+  // Pane: JSON Spec
+  // ---------------------------------------------------------------------------
+  const codePane = (
+    <div className="h-full flex flex-col">
+      <div className="border-b border-border px-3 h-9 flex items-center gap-3">
+        <span className="text-xs font-mono text-foreground">json</span>
+        {generating && (
+          <Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
+        )}
+        <div className="flex-1" />
+        {activeSpec && <CopyButton text={jsonCode} />}
+      </div>
+      <div ref={codeScrollRef} className="flex-1 overflow-auto">
+        <pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
+          {jsonCode}
+        </pre>
+      </div>
+    </div>
+  );
+
+  // ---------------------------------------------------------------------------
+  // Pane: PDF Preview
+  // ---------------------------------------------------------------------------
+  const previewPane = (
+    <div className="h-full flex flex-col">
+      <div className="border-b border-border px-3 h-9 flex items-center gap-3">
+        <span className="text-xs font-mono text-foreground">preview</span>
+        {(generating || refreshing) && (
+          <Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
+        )}
+        <div className="flex-1" />
+        {activeSpec && (
+          <button
+            onClick={handleDownload}
+            className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
+          >
+            <Download className="h-3 w-3" />
+            download
+          </button>
+        )}
+      </div>
+      <div className="flex-1 relative bg-neutral-600">
+        {displayPdfUrl ? (
+          <iframe
+            key={displayPdfUrl}
+            src={displayPdfUrl}
+            className="h-full w-full border-none"
+            title="PDF preview"
+          />
+        ) : (
+          <div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
+            <FileText className="h-10 w-10" />
+            <p className="text-sm">
+              {selection.mode === "scratch"
+                ? "Enter a prompt to generate a PDF"
+                : "Select an example to preview"}
+            </p>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+
+  // ---------------------------------------------------------------------------
+  // Render
+  // ---------------------------------------------------------------------------
+  return (
+    <div className="h-dvh flex flex-col">
+      {/* Desktop: 3-pane resizable layout */}
+      <div className="hidden lg:flex flex-1 min-h-0">
+        <ResizablePanelGroup className="flex-1">
+          <ResizablePanel defaultSize={25} minSize={15}>
+            {chatPane}
+          </ResizablePanel>
+          <ResizableHandle />
+          <ResizablePanel defaultSize={35} minSize={20}>
+            {codePane}
+          </ResizablePanel>
+          <ResizableHandle />
+          <ResizablePanel defaultSize={40} minSize={20}>
+            {previewPane}
+          </ResizablePanel>
+        </ResizablePanelGroup>
+      </div>
+
+      {/* Mobile: toolbar + content + prompt */}
+      <div className="flex lg:hidden flex-col flex-1 min-h-0">
+        <div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0">
+          <button
+            onClick={() => setExamplesSheetOpen(true)}
+            className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
+          >
+            {selection.mode === "example" && currentExample
+              ? currentExample.label
+              : "scratch"}
+          </button>
+          {(["json", "preview"] as const).map((tab) => (
+            <button
+              key={tab}
+              onClick={() => setMobileView(tab)}
+              className={cn(
+                "text-xs font-mono transition-colors shrink-0",
+                mobileView === tab
+                  ? "text-foreground"
+                  : "text-muted-foreground hover:text-foreground",
+              )}
+            >
+              {tab}
+            </button>
+          ))}
+          {(generating || refreshing) && (
+            <Loader2 className="h-3 w-3 text-muted-foreground animate-spin shrink-0" />
+          )}
+          <div className="flex-1" />
+          {activeSpec && (
+            <button
+              onClick={handleDownload}
+              className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
+            >
+              <Download className="h-3 w-3" />
+            </button>
+          )}
+        </div>
+
+        <div ref={mobileCodeScrollRef} className="flex-1 min-h-0 overflow-auto">
+          {mobileView === "json" ? (
+            <pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
+              {jsonCode}
+            </pre>
+          ) : (
+            <div className="h-full relative bg-neutral-600">
+              {displayPdfUrl ? (
+                <iframe
+                  key={displayPdfUrl}
+                  src={displayPdfUrl}
+                  className="h-full w-full border-none"
+                  title="PDF preview"
+                />
+              ) : (
+                <div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
+                  <FileText className="h-10 w-10" />
+                  <p className="text-sm">Enter a prompt to generate a PDF</p>
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+
+        <div
+          className="border-t border-border p-3 shrink-0 cursor-text"
+          onMouseDown={(e) => {
+            const target = e.target as HTMLElement;
+            if (!target.closest("button") && target.tagName !== "TEXTAREA") {
+              e.preventDefault();
+              mobileInputRef.current?.focus();
+            }
+          }}
+        >
+          <textarea
+            ref={mobileInputRef}
+            value={prompt}
+            onChange={(e) => setPrompt(e.target.value)}
+            onKeyDown={handleKeyDown}
+            placeholder={
+              selection.mode === "scratch"
+                ? "Describe the PDF you want..."
+                : `Modify the ${currentExample?.label ?? "example"}...`
+            }
+            className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
+            rows={2}
+          />
+          <div className="flex justify-between items-center mt-2">
+            <span className="text-[11px] text-muted-foreground">
+              {selection.mode === "example" && currentExample
+                ? currentExample.label
+                : "scratch"}
+            </span>
+            {generating ? (
+              <button
+                onClick={handleStop}
+                className="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"
+              >
+                <Square className="h-3 w-3" fill="currentColor" />
+              </button>
+            ) : (
+              <button
+                onClick={handleGenerate}
+                disabled={!prompt.trim()}
+                className="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="Generate"
+              >
+                <ArrowRight className="h-3.5 w-3.5" />
+              </button>
+            )}
+          </div>
+        </div>
+
+        <Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
+          <SheetContent side="left" className="w-80 p-0">
+            <SheetTitle className="sr-only">Examples</SheetTitle>
+            <ScrollArea className="h-full">
+              <div className="p-2 space-y-1">
+                <p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
+                  start
+                </p>
+                <button
+                  onClick={() => select({ mode: "scratch" })}
+                  className={cn(
+                    "w-full text-left px-3 py-2 rounded text-sm transition-colors",
+                    selection.mode === "scratch"
+                      ? "bg-muted text-foreground"
+                      : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+                  )}
+                >
+                  From scratch
+                </button>
+                <p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
+                  examples
+                </p>
+                {examples.map((ex) => (
+                  <button
+                    key={ex.name}
+                    onClick={() =>
+                      select({ mode: "example", exampleName: ex.name })
+                    }
+                    className={cn(
+                      "w-full text-left px-3 py-2 rounded text-sm transition-colors",
+                      selection.mode === "example" &&
+                        selection.exampleName === ex.name
+                        ? "bg-muted text-foreground"
+                        : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+                    )}
+                  >
+                    <span className="font-medium">{ex.label}</span>
+                    <p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
+                      {ex.description}
+                    </p>
+                  </button>
+                ))}
+              </div>
+            </ScrollArea>
+          </SheetContent>
+        </Sheet>
+      </div>
+    </div>
+  );
+}

+ 23 - 0
examples/react-pdf/components.json

@@ -0,0 +1,23 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "app/globals.css",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "rtl": false,
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "registries": {}
+}

+ 64 - 0
examples/react-pdf/components/ui/button.tsx

@@ -0,0 +1,64 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        destructive:
+          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        ghost:
+          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-9 px-4 py-2 has-[>svg]:px-3",
+        xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+        icon: "size-9",
+        "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+        "icon-sm": "size-8",
+        "icon-lg": "size-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  },
+);
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"button"> &
+  VariantProps<typeof buttonVariants> & {
+    asChild?: boolean;
+  }) {
+  const Comp = asChild ? Slot.Root : "button";
+
+  return (
+    <Comp
+      data-slot="button"
+      data-variant={variant}
+      data-size={size}
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  );
+}
+
+export { Button, buttonVariants };

+ 53 - 0
examples/react-pdf/components/ui/resizable.tsx

@@ -0,0 +1,53 @@
+"use client";
+
+import { GripVerticalIcon } from "lucide-react";
+import * as ResizablePrimitive from "react-resizable-panels";
+
+import { cn } from "@/lib/utils";
+
+function ResizablePanelGroup({
+  className,
+  ...props
+}: ResizablePrimitive.GroupProps) {
+  return (
+    <ResizablePrimitive.Group
+      data-slot="resizable-panel-group"
+      className={cn(
+        "flex h-full w-full aria-[orientation=vertical]:flex-col",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
+  return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
+}
+
+function ResizableHandle({
+  withHandle,
+  className,
+  ...props
+}: ResizablePrimitive.SeparatorProps & {
+  withHandle?: boolean;
+}) {
+  return (
+    <ResizablePrimitive.Separator
+      data-slot="resizable-handle"
+      className={cn(
+        "bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
+        className,
+      )}
+      {...props}
+    >
+      {withHandle && (
+        <div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
+          <GripVerticalIcon className="size-2.5" />
+        </div>
+      )}
+    </ResizablePrimitive.Separator>
+  );
+}
+
+export { ResizableHandle, ResizablePanel, ResizablePanelGroup };

+ 58 - 0
examples/react-pdf/components/ui/scroll-area.tsx

@@ -0,0 +1,58 @@
+"use client";
+
+import * as React from "react";
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function ScrollArea({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
+  return (
+    <ScrollAreaPrimitive.Root
+      data-slot="scroll-area"
+      className={cn("relative", className)}
+      {...props}
+    >
+      <ScrollAreaPrimitive.Viewport
+        data-slot="scroll-area-viewport"
+        className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
+      >
+        {children}
+      </ScrollAreaPrimitive.Viewport>
+      <ScrollBar />
+      <ScrollAreaPrimitive.Corner />
+    </ScrollAreaPrimitive.Root>
+  );
+}
+
+function ScrollBar({
+  className,
+  orientation = "vertical",
+  ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
+  return (
+    <ScrollAreaPrimitive.ScrollAreaScrollbar
+      data-slot="scroll-area-scrollbar"
+      orientation={orientation}
+      className={cn(
+        "flex touch-none p-px transition-colors select-none",
+        orientation === "vertical" &&
+          "h-full w-2.5 border-l border-l-transparent",
+        orientation === "horizontal" &&
+          "h-2.5 flex-col border-t border-t-transparent",
+        className,
+      )}
+      {...props}
+    >
+      <ScrollAreaPrimitive.ScrollAreaThumb
+        data-slot="scroll-area-thumb"
+        className="bg-border relative flex-1 rounded-full"
+      />
+    </ScrollAreaPrimitive.ScrollAreaScrollbar>
+  );
+}
+
+export { ScrollArea, ScrollBar };

+ 28 - 0
examples/react-pdf/components/ui/separator.tsx

@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import { Separator as SeparatorPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+  className,
+  orientation = "horizontal",
+  decorative = true,
+  ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+  return (
+    <SeparatorPrimitive.Root
+      data-slot="separator"
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Separator };

+ 143 - 0
examples/react-pdf/components/ui/sheet.tsx

@@ -0,0 +1,143 @@
+"use client";
+
+import * as React from "react";
+import { XIcon } from "lucide-react";
+import { Dialog as SheetPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
+  return <SheetPrimitive.Root data-slot="sheet" {...props} />;
+}
+
+function SheetTrigger({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
+  return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
+}
+
+function SheetClose({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Close>) {
+  return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
+}
+
+function SheetPortal({
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
+  return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
+}
+
+function SheetOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
+  return (
+    <SheetPrimitive.Overlay
+      data-slot="sheet-overlay"
+      className={cn(
+        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function SheetContent({
+  className,
+  children,
+  side = "right",
+  showCloseButton = true,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Content> & {
+  side?: "top" | "right" | "bottom" | "left";
+  showCloseButton?: boolean;
+}) {
+  return (
+    <SheetPortal>
+      <SheetOverlay />
+      <SheetPrimitive.Content
+        data-slot="sheet-content"
+        className={cn(
+          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+          side === "right" &&
+            "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
+          side === "left" &&
+            "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
+          side === "top" &&
+            "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
+          side === "bottom" &&
+            "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
+          className,
+        )}
+        {...props}
+      >
+        {children}
+        {showCloseButton && (
+          <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
+            <XIcon className="size-4" />
+            <span className="sr-only">Close</span>
+          </SheetPrimitive.Close>
+        )}
+      </SheetPrimitive.Content>
+    </SheetPortal>
+  );
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="sheet-header"
+      className={cn("flex flex-col gap-1.5 p-4", className)}
+      {...props}
+    />
+  );
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="sheet-footer"
+      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
+      {...props}
+    />
+  );
+}
+
+function SheetTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Title>) {
+  return (
+    <SheetPrimitive.Title
+      data-slot="sheet-title"
+      className={cn("text-foreground font-semibold", className)}
+      {...props}
+    />
+  );
+}
+
+function SheetDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof SheetPrimitive.Description>) {
+  return (
+    <SheetPrimitive.Description
+      data-slot="sheet-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  );
+}
+
+export {
+  Sheet,
+  SheetTrigger,
+  SheetClose,
+  SheetContent,
+  SheetHeader,
+  SheetFooter,
+  SheetTitle,
+  SheetDescription,
+};

+ 91 - 0
examples/react-pdf/components/ui/tabs.tsx

@@ -0,0 +1,91 @@
+"use client";
+
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Tabs as TabsPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({
+  className,
+  orientation = "horizontal",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+  return (
+    <TabsPrimitive.Root
+      data-slot="tabs"
+      data-orientation={orientation}
+      orientation={orientation}
+      className={cn(
+        "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+const tabsListVariants = cva(
+  "rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
+  {
+    variants: {
+      variant: {
+        default: "bg-muted",
+        line: "gap-1 bg-transparent",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  },
+);
+
+function TabsList({
+  className,
+  variant = "default",
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.List> &
+  VariantProps<typeof tabsListVariants>) {
+  return (
+    <TabsPrimitive.List
+      data-slot="tabs-list"
+      data-variant={variant}
+      className={cn(tabsListVariants({ variant }), className)}
+      {...props}
+    />
+  );
+}
+
+function TabsTrigger({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+  return (
+    <TabsPrimitive.Trigger
+      data-slot="tabs-trigger"
+      className={cn(
+        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
+        "data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
+        "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+function TabsContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+  return (
+    <TabsPrimitive.Content
+      data-slot="tabs-content"
+      className={cn("flex-1 outline-none", className)}
+      {...props}
+    />
+  );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };

+ 18 - 0
examples/react-pdf/components/ui/textarea.tsx

@@ -0,0 +1,18 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+  return (
+    <textarea
+      data-slot="textarea"
+      className={cn(
+        "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        className,
+      )}
+      {...props}
+    />
+  );
+}
+
+export { Textarea };

+ 8 - 0
examples/react-pdf/lib/catalog.ts

@@ -0,0 +1,8 @@
+import { defineCatalog } from "@json-render/core";
+import { schema } from "@json-render/react-pdf/server";
+import { standardComponentDefinitions } from "@json-render/react-pdf/catalog";
+
+export const pdfCatalog = defineCatalog(schema, {
+  components: standardComponentDefinitions,
+  actions: {},
+});

+ 1396 - 0
examples/react-pdf/lib/examples.ts

@@ -0,0 +1,1396 @@
+import type { Spec } from "@json-render/core";
+
+export interface Example {
+  name: string;
+  label: string;
+  description: string;
+  spec: Spec;
+}
+
+export const examples: Example[] = [
+  // =========================================================================
+  // Invoice
+  // =========================================================================
+  {
+    name: "invoice",
+    label: "Invoice",
+    description:
+      "A professional invoice with company header, billing details, line items, and totals",
+    spec: {
+      root: "doc",
+      elements: {
+        doc: {
+          type: "Document",
+          props: { title: "Invoice #1042", author: "Acme Corp", subject: null },
+          children: ["page"],
+        },
+        page: {
+          type: "Page",
+          props: {
+            size: "A4",
+            orientation: null,
+            marginTop: 50,
+            marginBottom: 50,
+            marginLeft: 50,
+            marginRight: 50,
+            backgroundColor: null,
+          },
+          children: [
+            "header",
+            "spacer-1",
+            "billing-row",
+            "spacer-2",
+            "items-table",
+            "spacer-3",
+            "totals",
+            "spacer-4",
+            "footer",
+            "page-num",
+          ],
+        },
+
+        // Header
+        header: {
+          type: "Row",
+          props: {
+            gap: null,
+            alignItems: "flex-end",
+            justifyContent: "space-between",
+            padding: null,
+            flex: null,
+            wrap: null,
+          },
+          children: ["company-name", "invoice-info"],
+        },
+        "company-name": {
+          type: "Heading",
+          props: {
+            text: "Acme Corp",
+            level: "h1",
+            color: "#1a202c",
+            align: null,
+          },
+        },
+        "invoice-info": {
+          type: "Column",
+          props: {
+            gap: 2,
+            alignItems: "flex-end",
+            justifyContent: null,
+            padding: null,
+            flex: null,
+          },
+          children: ["inv-number", "inv-date", "inv-due"],
+        },
+        "inv-number": {
+          type: "Text",
+          props: {
+            text: "Invoice #1042",
+            fontSize: 12,
+            color: "#4a5568",
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "inv-date": {
+          type: "Text",
+          props: {
+            text: "Date: February 19, 2026",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "inv-due": {
+          type: "Text",
+          props: {
+            text: "Due: March 19, 2026",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+
+        "spacer-1": { type: "Spacer", props: { height: 30 } },
+
+        // Billing info
+        "billing-row": {
+          type: "Row",
+          props: {
+            gap: 40,
+            alignItems: null,
+            justifyContent: null,
+            padding: null,
+            flex: null,
+            wrap: null,
+          },
+          children: ["bill-from", "bill-to"],
+        },
+        "bill-from": {
+          type: "Column",
+          props: {
+            gap: 4,
+            alignItems: null,
+            justifyContent: null,
+            padding: null,
+            flex: 1,
+          },
+          children: ["from-label", "from-name", "from-addr", "from-email"],
+        },
+        "from-label": {
+          type: "Text",
+          props: {
+            text: "From",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "from-name": {
+          type: "Text",
+          props: {
+            text: "Acme Corp",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "from-addr": {
+          type: "Text",
+          props: {
+            text: "123 Business Ave, Suite 100\nSan Francisco, CA 94102",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.4,
+          },
+        },
+        "from-email": {
+          type: "Text",
+          props: {
+            text: "billing@acmecorp.com",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "bill-to": {
+          type: "Column",
+          props: {
+            gap: 4,
+            alignItems: null,
+            justifyContent: null,
+            padding: null,
+            flex: 1,
+          },
+          children: ["to-label", "to-name", "to-addr", "to-email"],
+        },
+        "to-label": {
+          type: "Text",
+          props: {
+            text: "Bill To",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "to-name": {
+          type: "Text",
+          props: {
+            text: "Globex Corporation",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "to-addr": {
+          type: "Text",
+          props: {
+            text: "456 Client Road\nNew York, NY 10001",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.4,
+          },
+        },
+        "to-email": {
+          type: "Text",
+          props: {
+            text: "accounts@globex.com",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+
+        "spacer-2": { type: "Spacer", props: { height: 30 } },
+
+        // Items table
+        "items-table": {
+          type: "Table",
+          props: {
+            columns: [
+              { header: "Description", width: "45%", align: null },
+              { header: "Qty", width: "10%", align: "center" },
+              { header: "Unit Price", width: "20%", align: "right" },
+              { header: "Amount", width: "25%", align: "right" },
+            ],
+            rows: [
+              ["Website Redesign", "1", "$5,000.00", "$5,000.00"],
+              ["SEO Optimization", "1", "$2,500.00", "$2,500.00"],
+              ["Content Writing (per page)", "12", "$150.00", "$1,800.00"],
+              ["Monthly Hosting", "6", "$50.00", "$300.00"],
+            ],
+            headerBackgroundColor: "#2d3748",
+            headerTextColor: "#ffffff",
+            borderColor: "#e2e8f0",
+            fontSize: 10,
+            striped: true,
+          },
+        },
+
+        "spacer-3": { type: "Spacer", props: { height: 20 } },
+
+        // Totals
+        totals: {
+          type: "Column",
+          props: {
+            gap: 4,
+            alignItems: "flex-end",
+            justifyContent: null,
+            padding: null,
+            flex: null,
+          },
+          children: ["subtotal", "tax", "divider-totals", "total"],
+        },
+        subtotal: {
+          type: "Text",
+          props: {
+            text: "Subtotal: $9,600.00",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        tax: {
+          type: "Text",
+          props: {
+            text: "Tax (8.5%): $816.00",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "divider-totals": {
+          type: "Divider",
+          props: {
+            color: "#2d3748",
+            thickness: 2,
+            marginTop: 4,
+            marginBottom: 4,
+          },
+        },
+        total: {
+          type: "Text",
+          props: {
+            text: "Total Due: $10,416.00",
+            fontSize: 14,
+            color: "#1a202c",
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+
+        "spacer-4": { type: "Spacer", props: { height: 40 } },
+
+        footer: {
+          type: "Text",
+          props: {
+            text: "Payment is due within 30 days. Please make checks payable to Acme Corp or wire transfer to the account details provided separately.",
+            fontSize: 9,
+            color: "#a0aec0",
+            align: "center",
+            fontWeight: null,
+            fontStyle: "italic",
+            lineHeight: 1.4,
+          },
+        },
+        "page-num": {
+          type: "PageNumber",
+          props: {
+            format: null,
+            fontSize: 8,
+            color: "#cbd5e0",
+            align: "center",
+          },
+        },
+      },
+    },
+  },
+
+  // =========================================================================
+  // Report
+  // =========================================================================
+  {
+    name: "report",
+    label: "Quarterly Report",
+    description:
+      "A business report with summary data, tables, and key findings",
+    spec: {
+      root: "doc",
+      elements: {
+        doc: {
+          type: "Document",
+          props: {
+            title: "Q4 2025 Report",
+            author: "Analytics Team",
+            subject: "Quarterly Performance",
+          },
+          children: ["page"],
+        },
+        page: {
+          type: "Page",
+          props: {
+            size: "A4",
+            orientation: null,
+            marginTop: 50,
+            marginBottom: 60,
+            marginLeft: 50,
+            marginRight: 50,
+            backgroundColor: null,
+          },
+          children: [
+            "title",
+            "subtitle",
+            "spacer-1",
+            "summary-heading",
+            "summary-text",
+            "spacer-2",
+            "metrics-table",
+            "spacer-3",
+            "findings-heading",
+            "findings-list",
+            "spacer-4",
+            "divider-1",
+            "recommendations-heading",
+            "recommendations-text",
+            "recommendations-list",
+            "page-num",
+          ],
+        },
+
+        title: {
+          type: "Heading",
+          props: {
+            text: "Q4 2025 Performance Report",
+            level: "h1",
+            color: "#1a202c",
+            align: "center",
+          },
+        },
+        subtitle: {
+          type: "Text",
+          props: {
+            text: "Analytics Division -- Prepared January 15, 2026",
+            fontSize: 11,
+            color: "#718096",
+            align: "center",
+            fontWeight: null,
+            fontStyle: "italic",
+            lineHeight: null,
+          },
+        },
+
+        "spacer-1": { type: "Spacer", props: { height: 30 } },
+
+        "summary-heading": {
+          type: "Heading",
+          props: {
+            text: "Executive Summary",
+            level: "h2",
+            color: null,
+            align: null,
+          },
+        },
+        "summary-text": {
+          type: "Text",
+          props: {
+            text: "Q4 2025 demonstrated strong growth across all key performance indicators. Revenue increased 23% year-over-year, driven primarily by enterprise client acquisitions and expansion of existing accounts. Customer retention remained above target at 94.2%, while new customer acquisition exceeded projections by 15%.",
+            fontSize: 11,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.6,
+          },
+        },
+
+        "spacer-2": { type: "Spacer", props: { height: 20 } },
+
+        "metrics-table": {
+          type: "Table",
+          props: {
+            columns: [
+              { header: "Metric", width: "35%", align: null },
+              { header: "Q3 2025", width: "20%", align: "right" },
+              { header: "Q4 2025", width: "20%", align: "right" },
+              { header: "Change", width: "25%", align: "right" },
+            ],
+            rows: [
+              ["Revenue", "$4.2M", "$5.1M", "+21.4%"],
+              ["New Customers", "142", "168", "+18.3%"],
+              ["Customer Retention", "93.1%", "94.2%", "+1.1pp"],
+              ["Avg. Deal Size", "$28,400", "$32,100", "+13.0%"],
+              ["Support Tickets", "1,245", "1,089", "-12.5%"],
+              ["NPS Score", "72", "78", "+6 pts"],
+            ],
+            headerBackgroundColor: "#3b82f6",
+            headerTextColor: "#ffffff",
+            borderColor: "#e2e8f0",
+            fontSize: 10,
+            striped: true,
+          },
+        },
+
+        "spacer-3": { type: "Spacer", props: { height: 20 } },
+
+        "findings-heading": {
+          type: "Heading",
+          props: {
+            text: "Key Findings",
+            level: "h2",
+            color: null,
+            align: null,
+          },
+        },
+        "findings-list": {
+          type: "List",
+          props: {
+            items: [
+              "Enterprise segment grew 34%, accounting for 62% of total revenue",
+              "Self-serve onboarding reduced time-to-value by 40%",
+              "APAC region showed strongest growth at 45% QoQ",
+              "Product usage increased 28% following the September feature release",
+              "Customer support resolution time decreased from 4.2hrs to 2.8hrs",
+            ],
+            ordered: false,
+            fontSize: 10,
+            color: "#4a5568",
+            spacing: 6,
+          },
+        },
+
+        "spacer-4": { type: "Spacer", props: { height: 20 } },
+
+        "divider-1": {
+          type: "Divider",
+          props: {
+            color: "#e2e8f0",
+            thickness: 1,
+            marginTop: null,
+            marginBottom: null,
+          },
+        },
+
+        "recommendations-heading": {
+          type: "Heading",
+          props: {
+            text: "Recommendations",
+            level: "h2",
+            color: null,
+            align: null,
+          },
+        },
+        "recommendations-text": {
+          type: "Text",
+          props: {
+            text: "Based on Q4 performance data, we recommend the following strategic priorities for Q1 2026:",
+            fontSize: 11,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.5,
+          },
+        },
+        "recommendations-list": {
+          type: "List",
+          props: {
+            items: [
+              "Increase APAC sales team headcount by 30% to capitalize on regional momentum",
+              "Invest in self-serve product analytics to further reduce onboarding friction",
+              "Launch enterprise-tier support SLA to improve retention in top accounts",
+              "Expand content marketing program targeting mid-market segment",
+            ],
+            ordered: true,
+            fontSize: 10,
+            color: "#4a5568",
+            spacing: 6,
+          },
+        },
+
+        "page-num": {
+          type: "PageNumber",
+          props: {
+            format: "Page {pageNumber} of {totalPages}",
+            fontSize: 9,
+            color: "#a0aec0",
+            align: "center",
+          },
+        },
+      },
+    },
+  },
+
+  // =========================================================================
+  // Resume
+  // =========================================================================
+  {
+    name: "resume",
+    label: "Resume",
+    description:
+      "A professional resume with contact info, experience, education, and skills",
+    spec: {
+      root: "doc",
+      elements: {
+        doc: {
+          type: "Document",
+          props: {
+            title: "Resume - Jane Cooper",
+            author: "Jane Cooper",
+            subject: null,
+          },
+          children: ["page"],
+        },
+        page: {
+          type: "Page",
+          props: {
+            size: "LETTER",
+            orientation: null,
+            marginTop: 40,
+            marginBottom: 40,
+            marginLeft: 50,
+            marginRight: 50,
+            backgroundColor: null,
+          },
+          children: [
+            "name",
+            "title-text",
+            "contact-row",
+            "divider-top",
+            "summary-heading",
+            "summary-text",
+            "divider-1",
+            "experience-heading",
+            "job-1-row",
+            "job-1-desc",
+            "job-1-highlights",
+            "spacer-1",
+            "job-2-row",
+            "job-2-desc",
+            "job-2-highlights",
+            "divider-2",
+            "education-heading",
+            "edu-row",
+            "divider-3",
+            "skills-heading",
+            "skills-row",
+          ],
+        },
+
+        name: {
+          type: "Heading",
+          props: {
+            text: "Jane Cooper",
+            level: "h1",
+            color: "#1a202c",
+            align: "center",
+          },
+        },
+        "title-text": {
+          type: "Text",
+          props: {
+            text: "Senior Software Engineer",
+            fontSize: 13,
+            color: "#4a5568",
+            align: "center",
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "contact-row": {
+          type: "Row",
+          props: {
+            gap: 16,
+            alignItems: null,
+            justifyContent: "center",
+            padding: 6,
+            flex: null,
+            wrap: null,
+          },
+          children: ["contact-email", "contact-phone", "contact-site"],
+        },
+        "contact-email": {
+          type: "Link",
+          props: {
+            text: "jane@example.com",
+            href: "mailto:jane@example.com",
+            fontSize: 10,
+            color: "#3b82f6",
+          },
+        },
+        "contact-phone": {
+          type: "Text",
+          props: {
+            text: "(555) 123-4567",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "contact-site": {
+          type: "Link",
+          props: {
+            text: "janecooper.dev",
+            href: "https://janecooper.dev",
+            fontSize: 10,
+            color: "#3b82f6",
+          },
+        },
+
+        "divider-top": {
+          type: "Divider",
+          props: {
+            color: "#2d3748",
+            thickness: 2,
+            marginTop: 8,
+            marginBottom: 8,
+          },
+        },
+
+        "summary-heading": {
+          type: "Heading",
+          props: {
+            text: "Summary",
+            level: "h3",
+            color: "#2d3748",
+            align: null,
+          },
+        },
+        "summary-text": {
+          type: "Text",
+          props: {
+            text: "Software engineer with 8+ years building high-performance web applications. Expertise in TypeScript, React, and distributed systems. Passionate about developer experience, accessibility, and mentoring engineering teams.",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.5,
+          },
+        },
+
+        "divider-1": {
+          type: "Divider",
+          props: {
+            color: "#e2e8f0",
+            thickness: 1,
+            marginTop: 10,
+            marginBottom: 10,
+          },
+        },
+
+        "experience-heading": {
+          type: "Heading",
+          props: {
+            text: "Experience",
+            level: "h3",
+            color: "#2d3748",
+            align: null,
+          },
+        },
+
+        "job-1-row": {
+          type: "Row",
+          props: {
+            gap: null,
+            alignItems: null,
+            justifyContent: "space-between",
+            padding: null,
+            flex: null,
+            wrap: null,
+          },
+          children: ["job-1-title", "job-1-dates"],
+        },
+        "job-1-title": {
+          type: "Text",
+          props: {
+            text: "Senior Software Engineer -- Vercel",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "job-1-dates": {
+          type: "Text",
+          props: {
+            text: "2022 -- Present",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: "italic",
+            lineHeight: null,
+          },
+        },
+        "job-1-desc": {
+          type: "Text",
+          props: {
+            text: "Led development of the Next.js deployment pipeline and developer tooling.",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "job-1-highlights": {
+          type: "List",
+          props: {
+            items: [
+              "Reduced build times by 45% through incremental compilation pipeline",
+              "Designed and shipped real-time collaboration features used by 50K+ teams",
+              "Mentored 4 junior engineers, 2 promoted to senior within 18 months",
+            ],
+            ordered: false,
+            fontSize: 9,
+            color: "#4a5568",
+            spacing: 3,
+          },
+        },
+
+        "spacer-1": { type: "Spacer", props: { height: 10 } },
+
+        "job-2-row": {
+          type: "Row",
+          props: {
+            gap: null,
+            alignItems: null,
+            justifyContent: "space-between",
+            padding: null,
+            flex: null,
+            wrap: null,
+          },
+          children: ["job-2-title", "job-2-dates"],
+        },
+        "job-2-title": {
+          type: "Text",
+          props: {
+            text: "Software Engineer -- Stripe",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "job-2-dates": {
+          type: "Text",
+          props: {
+            text: "2018 -- 2022",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: "italic",
+            lineHeight: null,
+          },
+        },
+        "job-2-desc": {
+          type: "Text",
+          props: {
+            text: "Full-stack development on the Payments Dashboard and Connect platform.",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "job-2-highlights": {
+          type: "List",
+          props: {
+            items: [
+              "Built the Connect Express onboarding flow, reducing merchant setup time by 60%",
+              "Implemented real-time fraud detection UI processing 10M+ events/day",
+              "Led migration from legacy REST APIs to GraphQL, improving data loading by 3x",
+            ],
+            ordered: false,
+            fontSize: 9,
+            color: "#4a5568",
+            spacing: 3,
+          },
+        },
+
+        "divider-2": {
+          type: "Divider",
+          props: {
+            color: "#e2e8f0",
+            thickness: 1,
+            marginTop: 10,
+            marginBottom: 10,
+          },
+        },
+
+        "education-heading": {
+          type: "Heading",
+          props: {
+            text: "Education",
+            level: "h3",
+            color: "#2d3748",
+            align: null,
+          },
+        },
+        "edu-row": {
+          type: "Row",
+          props: {
+            gap: null,
+            alignItems: null,
+            justifyContent: "space-between",
+            padding: null,
+            flex: null,
+            wrap: null,
+          },
+          children: ["edu-degree", "edu-year"],
+        },
+        "edu-degree": {
+          type: "Text",
+          props: {
+            text: "B.S. Computer Science -- Stanford University",
+            fontSize: 10,
+            color: "#4a5568",
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "edu-year": {
+          type: "Text",
+          props: {
+            text: "2014 -- 2018",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: "italic",
+            lineHeight: null,
+          },
+        },
+
+        "divider-3": {
+          type: "Divider",
+          props: {
+            color: "#e2e8f0",
+            thickness: 1,
+            marginTop: 10,
+            marginBottom: 10,
+          },
+        },
+
+        "skills-heading": {
+          type: "Heading",
+          props: { text: "Skills", level: "h3", color: "#2d3748", align: null },
+        },
+        "skills-row": {
+          type: "Row",
+          props: {
+            gap: 20,
+            alignItems: null,
+            justifyContent: null,
+            padding: null,
+            flex: null,
+            wrap: true,
+          },
+          children: ["skills-langs", "skills-frameworks", "skills-infra"],
+        },
+        "skills-langs": {
+          type: "Column",
+          props: {
+            gap: 2,
+            alignItems: null,
+            justifyContent: null,
+            padding: null,
+            flex: 1,
+          },
+          children: ["skills-langs-label", "skills-langs-list"],
+        },
+        "skills-langs-label": {
+          type: "Text",
+          props: {
+            text: "Languages",
+            fontSize: 9,
+            color: "#718096",
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "skills-langs-list": {
+          type: "Text",
+          props: {
+            text: "TypeScript, JavaScript, Python, Go, Rust, SQL",
+            fontSize: 9,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.4,
+          },
+        },
+        "skills-frameworks": {
+          type: "Column",
+          props: {
+            gap: 2,
+            alignItems: null,
+            justifyContent: null,
+            padding: null,
+            flex: 1,
+          },
+          children: ["skills-fw-label", "skills-fw-list"],
+        },
+        "skills-fw-label": {
+          type: "Text",
+          props: {
+            text: "Frameworks",
+            fontSize: 9,
+            color: "#718096",
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "skills-fw-list": {
+          type: "Text",
+          props: {
+            text: "React, Next.js, Node.js, Express, GraphQL, Prisma",
+            fontSize: 9,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.4,
+          },
+        },
+        "skills-infra": {
+          type: "Column",
+          props: {
+            gap: 2,
+            alignItems: null,
+            justifyContent: null,
+            padding: null,
+            flex: 1,
+          },
+          children: ["skills-infra-label", "skills-infra-list"],
+        },
+        "skills-infra-label": {
+          type: "Text",
+          props: {
+            text: "Infrastructure",
+            fontSize: 9,
+            color: "#718096",
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "skills-infra-list": {
+          type: "Text",
+          props: {
+            text: "AWS, Vercel, Docker, Kubernetes, Terraform, PostgreSQL",
+            fontSize: 9,
+            color: "#4a5568",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.4,
+          },
+        },
+      },
+    },
+  },
+
+  // =========================================================================
+  // Letter
+  // =========================================================================
+  {
+    name: "letter",
+    label: "Business Letter",
+    description:
+      "A formal business letter with letterhead, body paragraphs, and signature",
+    spec: {
+      root: "doc",
+      elements: {
+        doc: {
+          type: "Document",
+          props: {
+            title: "Business Letter",
+            author: "Acme Corp",
+            subject: null,
+          },
+          children: ["page"],
+        },
+        page: {
+          type: "Page",
+          props: {
+            size: "LETTER",
+            orientation: null,
+            marginTop: 60,
+            marginBottom: 60,
+            marginLeft: 70,
+            marginRight: 70,
+            backgroundColor: null,
+          },
+          children: [
+            "letterhead",
+            "divider-head",
+            "spacer-1",
+            "date",
+            "spacer-2",
+            "recipient",
+            "spacer-3",
+            "greeting",
+            "spacer-4",
+            "body-1",
+            "spacer-5",
+            "body-2",
+            "spacer-6",
+            "body-3",
+            "spacer-7",
+            "closing",
+            "spacer-8",
+            "signature",
+            "sender-title",
+            "page-num",
+          ],
+        },
+
+        letterhead: {
+          type: "Row",
+          props: {
+            gap: null,
+            alignItems: "center",
+            justifyContent: "space-between",
+            padding: null,
+            flex: null,
+            wrap: null,
+          },
+          children: ["lh-company", "lh-contact"],
+        },
+        "lh-company": {
+          type: "Heading",
+          props: {
+            text: "Acme Corp",
+            level: "h2",
+            color: "#2d3748",
+            align: null,
+          },
+        },
+        "lh-contact": {
+          type: "Column",
+          props: {
+            gap: 2,
+            alignItems: "flex-end",
+            justifyContent: null,
+            padding: null,
+            flex: null,
+          },
+          children: ["lh-addr", "lh-phone", "lh-web"],
+        },
+        "lh-addr": {
+          type: "Text",
+          props: {
+            text: "123 Business Ave, San Francisco, CA 94102",
+            fontSize: 9,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "lh-phone": {
+          type: "Text",
+          props: {
+            text: "(555) 987-6543",
+            fontSize: 9,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "lh-web": {
+          type: "Link",
+          props: {
+            text: "www.acmecorp.com",
+            href: "https://acmecorp.com",
+            fontSize: 9,
+            color: "#3b82f6",
+          },
+        },
+
+        "divider-head": {
+          type: "Divider",
+          props: {
+            color: "#2d3748",
+            thickness: 2,
+            marginTop: 12,
+            marginBottom: null,
+          },
+        },
+
+        "spacer-1": { type: "Spacer", props: { height: 30 } },
+
+        date: {
+          type: "Text",
+          props: {
+            text: "February 19, 2026",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+
+        "spacer-2": { type: "Spacer", props: { height: 20 } },
+
+        recipient: {
+          type: "Column",
+          props: {
+            gap: 2,
+            alignItems: null,
+            justifyContent: null,
+            padding: null,
+            flex: null,
+          },
+          children: ["rcpt-name", "rcpt-title", "rcpt-company", "rcpt-addr"],
+        },
+        "rcpt-name": {
+          type: "Text",
+          props: {
+            text: "Mr. John Smith",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "rcpt-title": {
+          type: "Text",
+          props: {
+            text: "Director of Partnerships",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "rcpt-company": {
+          type: "Text",
+          props: {
+            text: "Globex Corporation",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "rcpt-addr": {
+          type: "Text",
+          props: {
+            text: "456 Client Road, New York, NY 10001",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+
+        "spacer-3": { type: "Spacer", props: { height: 20 } },
+
+        greeting: {
+          type: "Text",
+          props: {
+            text: "Dear Mr. Smith,",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+
+        "spacer-4": { type: "Spacer", props: { height: 12 } },
+
+        "body-1": {
+          type: "Text",
+          props: {
+            text: "Thank you for taking the time to meet with our team last Thursday. We greatly enjoyed the conversation about potential collaboration opportunities between Acme Corp and Globex Corporation, and we are excited about the prospect of working together.",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.6,
+          },
+        },
+
+        "spacer-5": { type: "Spacer", props: { height: 10 } },
+
+        "body-2": {
+          type: "Text",
+          props: {
+            text: "As discussed, we believe our enterprise platform can significantly streamline your operations while reducing costs by an estimated 25-30%. Our team has prepared a detailed proposal outlining the implementation timeline, resource requirements, and projected ROI. I have enclosed this document for your review.",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.6,
+          },
+        },
+
+        "spacer-6": { type: "Spacer", props: { height: 10 } },
+
+        "body-3": {
+          type: "Text",
+          props: {
+            text: "We would welcome the opportunity to schedule a follow-up meeting to address any questions you may have and discuss next steps. Please do not hesitate to reach out at your convenience. We look forward to the possibility of a fruitful partnership.",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: 1.6,
+          },
+        },
+
+        "spacer-7": { type: "Spacer", props: { height: 24 } },
+
+        closing: {
+          type: "Text",
+          props: {
+            text: "Sincerely,",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+
+        "spacer-8": { type: "Spacer", props: { height: 30 } },
+
+        signature: {
+          type: "Text",
+          props: {
+            text: "Sarah Johnson",
+            fontSize: 11,
+            color: null,
+            align: null,
+            fontWeight: "bold",
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+        "sender-title": {
+          type: "Text",
+          props: {
+            text: "VP of Business Development, Acme Corp",
+            fontSize: 10,
+            color: "#718096",
+            align: null,
+            fontWeight: null,
+            fontStyle: null,
+            lineHeight: null,
+          },
+        },
+
+        "page-num": {
+          type: "PageNumber",
+          props: {
+            format: null,
+            fontSize: 8,
+            color: "#cbd5e0",
+            align: "center",
+          },
+        },
+      },
+    },
+  },
+];

+ 6 - 0
examples/react-pdf/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 6 - 0
examples/react-pdf/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 7 - 0
examples/react-pdf/next.config.ts

@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  serverExternalPackages: ["@react-pdf/renderer", "@json-render/react-pdf"],
+};
+
+export default nextConfig;

+ 39 - 0
examples/react-pdf/package.json

@@ -0,0 +1,39 @@
+{
+  "name": "example-react-pdf",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --turbopack --port 3005",
+    "build": "next build",
+    "start": "next start",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@ai-sdk/gateway": "^3.0.52",
+    "@json-render/core": "workspace:*",
+    "@json-render/react-pdf": "workspace:*",
+    "@tailwindcss/postcss": "^4.1.18",
+    "ai": "6.0.94",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "lucide-react": "^0.575.0",
+    "next": "16.1.6",
+    "radix-ui": "^1.4.3",
+    "react": "19.2.4",
+    "react-dom": "19.2.4",
+    "react-resizable-panels": "^4.4.1",
+    "tailwind-merge": "^3.5.0",
+    "tailwindcss": "^4.1.18",
+    "zod": "4.3.5"
+  },
+  "devDependencies": {
+    "@repo/typescript-config": "workspace:*",
+    "@types/node": "^22.10.0",
+    "@types/react": "19.2.3",
+    "@types/react-dom": "19.2.3",
+    "shadcn": "^3.8.5",
+    "tw-animate-css": "^1.4.0",
+    "typescript": "^5.7.2"
+  }
+}

+ 8 - 0
examples/react-pdf/postcss.config.mjs

@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};
+
+export default config;

+ 13 - 0
examples/react-pdf/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "declaration": false,
+    "declarationMap": false,
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 1 - 1
examples/remotion/next-env.d.ts

@@ -1,6 +1,6 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 1 - 1
examples/stripe-app/api/next-env.d.ts

@@ -1,6 +1,6 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 167 - 0
packages/react-pdf/README.md

@@ -0,0 +1,167 @@
+# @json-render/react-pdf
+
+React PDF renderer for `@json-render/core`. Generate PDF documents from JSON specs using `@react-pdf/renderer`.
+
+## Install
+
+```bash
+npm install @json-render/core @json-render/react-pdf
+```
+
+## Quick Start
+
+### Render a spec to a PDF buffer
+
+```typescript
+import { renderToBuffer } from "@json-render/react-pdf";
+import type { Spec } from "@json-render/core";
+
+const spec: Spec = {
+  root: "doc",
+  elements: {
+    doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
+    page: {
+      type: "Page",
+      props: { size: "A4" },
+      children: ["heading", "table"],
+    },
+    heading: {
+      type: "Heading",
+      props: { text: "Invoice #1234", level: "h1" },
+      children: [],
+    },
+    table: {
+      type: "Table",
+      props: {
+        columns: [
+          { header: "Item", width: "60%" },
+          { header: "Price", width: "40%", align: "right" },
+        ],
+        rows: [
+          ["Widget A", "$10.00"],
+          ["Widget B", "$25.00"],
+        ],
+      },
+      children: [],
+    },
+  },
+};
+
+const buffer = await renderToBuffer(spec);
+```
+
+### With a custom catalog
+
+```typescript
+import { defineCatalog } from "@json-render/core";
+import { schema, defineRegistry, renderToBuffer } from "@json-render/react-pdf";
+import { standardComponentDefinitions } from "@json-render/react-pdf/catalog";
+import { z } from "zod";
+
+const catalog = defineCatalog(schema, {
+  components: {
+    ...standardComponentDefinitions,
+    Badge: {
+      props: z.object({
+        label: z.string(),
+        color: z.string().nullable(),
+      }),
+      slots: [],
+      description: "A colored badge label",
+    },
+  },
+  actions: {},
+});
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    Badge: ({ props }) => (
+      <View style={{ backgroundColor: props.color ?? "#e5e7eb", padding: 4, borderRadius: 4 }}>
+        <Text style={{ fontSize: 10 }}>{props.label}</Text>
+      </View>
+    ),
+  },
+});
+
+const buffer = await renderToBuffer(spec, { registry });
+```
+
+## Standard Components
+
+### Document Structure
+
+| Component | Description |
+|-----------|-------------|
+| `Document` | Top-level PDF wrapper. Must be the root element. |
+| `Page` | A page with size (A4, LETTER, etc.), orientation, and margins. |
+
+### Layout
+
+| Component | Description |
+|-----------|-------------|
+| `View` | Generic container with padding, margin, background, border. |
+| `Row` | Horizontal flex layout with gap, align, justify. |
+| `Column` | Vertical flex layout with gap, align, justify. |
+
+### Content
+
+| Component | Description |
+|-----------|-------------|
+| `Heading` | h1-h4 heading text. |
+| `Text` | Body text with fontSize, color, weight, alignment. |
+| `Image` | Image from URL or base64. |
+| `Link` | Hyperlink with text and href. |
+
+### Data
+
+| Component | Description |
+|-----------|-------------|
+| `Table` | Data table with typed columns and string rows. |
+| `List` | Ordered or unordered list. |
+
+### Decorative
+
+| Component | Description |
+|-----------|-------------|
+| `Divider` | Horizontal line separator. |
+| `Spacer` | Empty vertical space. |
+
+### Page-Level
+
+| Component | Description |
+|-----------|-------------|
+| `PageNumber` | Renders current page number and total pages. |
+
+## Server-Side APIs
+
+```typescript
+import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
+
+// Render to an in-memory Buffer
+const buffer = await renderToBuffer(spec);
+
+// Render to a readable stream (pipe to HTTP response)
+const stream = await renderToStream(spec);
+stream.pipe(res);
+
+// Render directly to a file
+await renderToFile(spec, "./output.pdf");
+```
+
+All render functions accept an optional second argument with:
+
+- `registry` - Custom component registry (merged with standard components)
+- `state` - Initial state for `$state` / `$cond` dynamic prop resolution
+- `handlers` - Action handlers
+
+## Server-Safe Import
+
+Import schema and catalog definitions without pulling in React:
+
+```typescript
+import { schema, standardComponentDefinitions } from "@json-render/react-pdf/server";
+```
+
+## License
+
+Apache-2.0

+ 77 - 0
packages/react-pdf/package.json

@@ -0,0 +1,77 @@
+{
+  "name": "@json-render/react-pdf",
+  "version": "0.7.0",
+  "license": "Apache-2.0",
+  "description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
+  "keywords": [
+    "json",
+    "pdf",
+    "react-pdf",
+    "ai",
+    "generative-ui",
+    "llm",
+    "renderer",
+    "documents"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/react-pdf"
+  },
+  "homepage": "https://github.com/vercel-labs/json-render#readme",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    },
+    "./server": {
+      "types": "./dist/server.d.ts",
+      "import": "./dist/server.mjs",
+      "require": "./dist/server.js"
+    },
+    "./catalog": {
+      "types": "./dist/catalog.d.ts",
+      "import": "./dist/catalog.mjs",
+      "require": "./dist/catalog.js"
+    },
+    "./render": {
+      "types": "./dist/render.d.ts",
+      "import": "./dist/render.mjs",
+      "require": "./dist/render.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "check-types": "tsc --noEmit",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@react-pdf/renderer": "^4.3.2"
+  },
+  "devDependencies": {
+    "@repo/typescript-config": "workspace:*",
+    "@types/react": "19.2.3",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "zod": "^4.0.0"
+  },
+  "peerDependencies": {
+    "react": "^19.0.0",
+    "zod": "^4.0.0"
+  }
+}

+ 41 - 0
packages/react-pdf/src/catalog-types.ts

@@ -0,0 +1,41 @@
+import type { ReactNode } from "react";
+import type {
+  Catalog,
+  InferCatalogComponents,
+  InferComponentProps,
+  StateModel,
+} from "@json-render/core";
+
+export type { StateModel };
+
+// =============================================================================
+// State Types
+// =============================================================================
+
+export type SetState = (
+  updater: (prev: Record<string, unknown>) => Record<string, unknown>,
+) => void;
+
+// =============================================================================
+// Component Types
+// =============================================================================
+
+export interface ComponentContext<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> {
+  props: InferComponentProps<C, K>;
+  children?: ReactNode;
+  emit: (event: string) => void;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}
+
+export type ComponentFn<
+  C extends Catalog,
+  K extends keyof InferCatalogComponents<C>,
+> = (ctx: ComponentContext<C, K>) => ReactNode;
+
+export type Components<C extends Catalog> = {
+  [K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
+};

+ 294 - 0
packages/react-pdf/src/catalog.ts

@@ -0,0 +1,294 @@
+import { z } from "zod";
+
+/**
+ * Standard component definitions for React PDF catalogs.
+ *
+ * These define the available PDF components with their Zod prop schemas.
+ * All components render using @react-pdf/renderer primitives.
+ */
+export const standardComponentDefinitions = {
+  // ==========================================================================
+  // Document Structure
+  // ==========================================================================
+
+  Document: {
+    props: z.object({
+      title: z.string().nullable(),
+      author: z.string().nullable(),
+      subject: z.string().nullable(),
+    }),
+    slots: ["default"],
+    description:
+      "Top-level PDF document wrapper. Must be the root element. Children must be Page components.",
+    example: { title: "Invoice #1234", author: "Acme Corp" },
+  },
+
+  Page: {
+    props: z.object({
+      size: z.enum(["A4", "A3", "A5", "LETTER", "LEGAL", "TABLOID"]).nullable(),
+      orientation: z.enum(["portrait", "landscape"]).nullable(),
+      marginTop: z.number().nullable(),
+      marginBottom: z.number().nullable(),
+      marginLeft: z.number().nullable(),
+      marginRight: z.number().nullable(),
+      backgroundColor: z.string().nullable(),
+    }),
+    slots: ["default"],
+    description:
+      "A page in the PDF document. Set size and orientation. Children are laid out vertically by default.",
+    example: { size: "A4", orientation: "portrait" },
+  },
+
+  // ==========================================================================
+  // Layout Components
+  // ==========================================================================
+
+  View: {
+    props: z.object({
+      padding: z.number().nullable(),
+      paddingTop: z.number().nullable(),
+      paddingBottom: z.number().nullable(),
+      paddingLeft: z.number().nullable(),
+      paddingRight: z.number().nullable(),
+      margin: z.number().nullable(),
+      backgroundColor: z.string().nullable(),
+      borderWidth: z.number().nullable(),
+      borderColor: z.string().nullable(),
+      borderRadius: z.number().nullable(),
+      flex: z.number().nullable(),
+      alignItems: z
+        .enum(["flex-start", "center", "flex-end", "stretch"])
+        .nullable(),
+      justifyContent: z
+        .enum([
+          "flex-start",
+          "center",
+          "flex-end",
+          "space-between",
+          "space-around",
+        ])
+        .nullable(),
+    }),
+    slots: ["default"],
+    description:
+      "Generic container for grouping elements. Supports padding, margin, background, border, and flex alignment.",
+    example: { padding: 10, backgroundColor: "#f9f9f9", alignItems: "center" },
+  },
+
+  Row: {
+    props: z.object({
+      gap: z.number().nullable(),
+      alignItems: z
+        .enum(["flex-start", "center", "flex-end", "stretch"])
+        .nullable(),
+      justifyContent: z
+        .enum([
+          "flex-start",
+          "center",
+          "flex-end",
+          "space-between",
+          "space-around",
+        ])
+        .nullable(),
+      padding: z.number().nullable(),
+      flex: z.number().nullable(),
+      wrap: z.boolean().nullable(),
+    }),
+    slots: ["default"],
+    description:
+      "Horizontal flex layout. Use for placing elements side by side.",
+    example: { gap: 10, alignItems: "center" },
+  },
+
+  Column: {
+    props: z.object({
+      gap: z.number().nullable(),
+      alignItems: z
+        .enum(["flex-start", "center", "flex-end", "stretch"])
+        .nullable(),
+      justifyContent: z
+        .enum([
+          "flex-start",
+          "center",
+          "flex-end",
+          "space-between",
+          "space-around",
+        ])
+        .nullable(),
+      padding: z.number().nullable(),
+      flex: z.number().nullable(),
+    }),
+    slots: ["default"],
+    description:
+      "Vertical flex layout. Use for stacking elements top to bottom.",
+    example: { gap: 8, padding: 10 },
+  },
+
+  // ==========================================================================
+  // Content Components
+  // ==========================================================================
+
+  Heading: {
+    props: z.object({
+      text: z.string(),
+      level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
+      color: z.string().nullable(),
+      align: z.enum(["left", "center", "right"]).nullable(),
+    }),
+    slots: [],
+    description:
+      "Heading text at various levels. h1 is largest, h4 is smallest.",
+    example: { text: "Invoice", level: "h1" },
+  },
+
+  Text: {
+    props: z.object({
+      text: z.string(),
+      fontSize: z.number().nullable(),
+      color: z.string().nullable(),
+      align: z.enum(["left", "center", "right"]).nullable(),
+      fontWeight: z.enum(["normal", "bold"]).nullable(),
+      fontStyle: z.enum(["normal", "italic"]).nullable(),
+      lineHeight: z.number().nullable(),
+    }),
+    slots: [],
+    description:
+      "Body text with configurable size, color, weight, and alignment.",
+    example: { text: "Thank you for your business." },
+  },
+
+  Image: {
+    props: z.object({
+      src: z.string(),
+      width: z.number().nullable(),
+      height: z.number().nullable(),
+      objectFit: z.enum(["contain", "cover", "fill", "none"]).nullable(),
+    }),
+    slots: [],
+    description:
+      "Image from a URL. Specify width and/or height to control size. For placeholder/stock images use https://picsum.photos/{width}/{height}?random={n} where {n} is a unique number per image.",
+    example: {
+      src: "https://picsum.photos/400/300?random=1",
+      width: 400,
+      height: 300,
+    },
+  },
+
+  Link: {
+    props: z.object({
+      text: z.string(),
+      href: z.string(),
+      fontSize: z.number().nullable(),
+      color: z.string().nullable(),
+    }),
+    slots: [],
+    description: "Hyperlink with visible text and a URL.",
+    example: { text: "Visit our website", href: "https://example.com" },
+  },
+
+  // ==========================================================================
+  // Data Components
+  // ==========================================================================
+
+  Table: {
+    props: z.object({
+      columns: z.array(
+        z.object({
+          header: z.string(),
+          width: z.string().nullable(),
+          align: z.enum(["left", "center", "right"]).nullable(),
+        }),
+      ),
+      rows: z.array(z.array(z.string())),
+      headerBackgroundColor: z.string().nullable(),
+      headerTextColor: z.string().nullable(),
+      borderColor: z.string().nullable(),
+      fontSize: z.number().nullable(),
+      striped: z.boolean().nullable(),
+    }),
+    slots: [],
+    description:
+      "Data table with typed columns and rows. Each row is a string array matching the column count.",
+    example: {
+      columns: [
+        { header: "Item", width: "60%" },
+        { header: "Price", width: "40%", align: "right" },
+      ],
+      rows: [
+        ["Widget A", "$10.00"],
+        ["Widget B", "$25.00"],
+      ],
+    },
+  },
+
+  List: {
+    props: z.object({
+      items: z.array(z.string()),
+      ordered: z.boolean().nullable(),
+      fontSize: z.number().nullable(),
+      color: z.string().nullable(),
+      spacing: z.number().nullable(),
+    }),
+    slots: [],
+    description:
+      "Ordered or unordered list. Each item is rendered as a text line with a bullet or number.",
+    example: {
+      items: ["First item", "Second item", "Third item"],
+      ordered: false,
+    },
+  },
+
+  // ==========================================================================
+  // Decorative Components
+  // ==========================================================================
+
+  Divider: {
+    props: z.object({
+      color: z.string().nullable(),
+      thickness: z.number().nullable(),
+      marginTop: z.number().nullable(),
+      marginBottom: z.number().nullable(),
+    }),
+    slots: [],
+    description: "Horizontal line separator between content sections.",
+    example: { color: "#e5e7eb", thickness: 1 },
+  },
+
+  Spacer: {
+    props: z.object({
+      height: z.number().nullable(),
+    }),
+    slots: [],
+    description: "Empty vertical space between elements.",
+    example: { height: 20 },
+  },
+
+  // ==========================================================================
+  // Page-Level Components
+  // ==========================================================================
+
+  PageNumber: {
+    props: z.object({
+      format: z.string().nullable(),
+      fontSize: z.number().nullable(),
+      color: z.string().nullable(),
+      align: z.enum(["left", "center", "right"]).nullable(),
+    }),
+    slots: [],
+    description:
+      'Renders the current page number and total pages. Format uses {pageNumber} and {totalPages} placeholders, e.g. "Page {pageNumber} of {totalPages}". Default: "{pageNumber} / {totalPages}".',
+    example: {
+      format: "Page {pageNumber} of {totalPages}",
+      align: "center",
+      fontSize: 10,
+    },
+  },
+};
+
+export type StandardComponentDefinitions = typeof standardComponentDefinitions;
+
+export type StandardComponentProps<
+  K extends keyof StandardComponentDefinitions,
+> = StandardComponentDefinitions[K]["props"] extends { _output: infer O }
+  ? O
+  : z.output<StandardComponentDefinitions[K]["props"]>;

+ 1 - 0
packages/react-pdf/src/components/index.ts

@@ -0,0 +1 @@
+export { standardComponents } from "./standard";

+ 459 - 0
packages/react-pdf/src/components/standard.tsx

@@ -0,0 +1,459 @@
+import React from "react";
+import {
+  Document as PdfDocument,
+  Page as PdfPage,
+  View,
+  Text as PdfText,
+  Image as PdfImage,
+  Link as PdfLink,
+  StyleSheet,
+} from "@react-pdf/renderer";
+import type { ComponentRenderProps } from "../renderer";
+import type { ComponentRegistry } from "../renderer";
+import type { StandardComponentProps } from "../catalog";
+
+const EMOJI_RE =
+  /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu;
+
+function stripEmoji(text: string): string {
+  return text
+    .replace(EMOJI_RE, "")
+    .replace(/\s{2,}/g, " ")
+    .trim();
+}
+
+// =============================================================================
+// Document Structure
+// =============================================================================
+
+function DocumentComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Document">>) {
+  const p = element.props;
+
+  return (
+    <PdfDocument
+      title={p.title ?? undefined}
+      author={p.author ?? undefined}
+      subject={p.subject ?? undefined}
+    >
+      {children}
+    </PdfDocument>
+  );
+}
+
+function PageComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Page">>) {
+  const p = element.props;
+
+  return (
+    <PdfPage
+      size={(p.size as any) ?? "A4"}
+      orientation={p.orientation ?? "portrait"}
+      style={{
+        paddingTop: p.marginTop ?? 40,
+        paddingBottom: p.marginBottom ?? 40,
+        paddingLeft: p.marginLeft ?? 40,
+        paddingRight: p.marginRight ?? 40,
+        backgroundColor: p.backgroundColor ?? undefined,
+        fontFamily: "Helvetica",
+        fontSize: 12,
+      }}
+    >
+      {children}
+    </PdfPage>
+  );
+}
+
+// =============================================================================
+// Layout Components
+// =============================================================================
+
+function ViewComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"View">>) {
+  const p = element.props;
+
+  return (
+    <View
+      style={{
+        padding: p.padding ?? undefined,
+        paddingTop: p.paddingTop ?? undefined,
+        paddingBottom: p.paddingBottom ?? undefined,
+        paddingLeft: p.paddingLeft ?? undefined,
+        paddingRight: p.paddingRight ?? undefined,
+        margin: p.margin ?? undefined,
+        backgroundColor: p.backgroundColor ?? undefined,
+        borderWidth: p.borderWidth ?? undefined,
+        borderColor: p.borderColor ?? undefined,
+        borderRadius: p.borderRadius ?? undefined,
+        flex: p.flex ?? undefined,
+        alignItems: (p.alignItems as any) ?? undefined,
+        justifyContent: (p.justifyContent as any) ?? undefined,
+      }}
+    >
+      {children}
+    </View>
+  );
+}
+
+function RowComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Row">>) {
+  const p = element.props;
+
+  return (
+    <View
+      style={{
+        flexDirection: "row",
+        gap: p.gap ?? undefined,
+        alignItems: (p.alignItems as any) ?? undefined,
+        justifyContent: (p.justifyContent as any) ?? undefined,
+        padding: p.padding ?? undefined,
+        flex: p.flex ?? undefined,
+        flexWrap: p.wrap ? "wrap" : undefined,
+      }}
+    >
+      {children}
+    </View>
+  );
+}
+
+function ColumnComponent({
+  element,
+  children,
+}: ComponentRenderProps<StandardComponentProps<"Column">>) {
+  const p = element.props;
+
+  return (
+    <View
+      style={{
+        flexDirection: "column",
+        gap: p.gap ?? undefined,
+        alignItems: (p.alignItems as any) ?? undefined,
+        justifyContent: (p.justifyContent as any) ?? undefined,
+        padding: p.padding ?? undefined,
+        flex: p.flex ?? undefined,
+      }}
+    >
+      {children}
+    </View>
+  );
+}
+
+// =============================================================================
+// Content Components
+// =============================================================================
+
+const headingStyles = StyleSheet.create({
+  h1: { fontSize: 24, fontFamily: "Helvetica-Bold", marginBottom: 8 },
+  h2: { fontSize: 20, fontFamily: "Helvetica-Bold", marginBottom: 6 },
+  h3: { fontSize: 16, fontFamily: "Helvetica-Bold", marginBottom: 4 },
+  h4: { fontSize: 14, fontFamily: "Helvetica-Bold", marginBottom: 4 },
+});
+
+function HeadingComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Heading">>) {
+  const p = element.props;
+  const level = p.level ?? "h2";
+
+  return (
+    <PdfText
+      style={[
+        headingStyles[level],
+        {
+          color: p.color ?? undefined,
+          textAlign: p.align ?? "left",
+        },
+      ]}
+    >
+      {stripEmoji(p.text)}
+    </PdfText>
+  );
+}
+
+function TextComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Text">>) {
+  const p = element.props;
+
+  return (
+    <PdfText
+      style={{
+        fontSize: p.fontSize ?? 12,
+        color: p.color ?? undefined,
+        textAlign: p.align ?? "left",
+        fontFamily:
+          p.fontWeight === "bold" && p.fontStyle === "italic"
+            ? "Helvetica-BoldOblique"
+            : p.fontWeight === "bold"
+              ? "Helvetica-Bold"
+              : p.fontStyle === "italic"
+                ? "Helvetica-Oblique"
+                : "Helvetica",
+        lineHeight: p.lineHeight ?? undefined,
+      }}
+    >
+      {stripEmoji(p.text)}
+    </PdfText>
+  );
+}
+
+function ImageComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Image">>) {
+  const p = element.props;
+
+  return (
+    <PdfImage
+      src={p.src}
+      style={{
+        width: p.width ?? undefined,
+        height: p.height ?? undefined,
+        objectFit: p.objectFit ?? "contain",
+      }}
+    />
+  );
+}
+
+function LinkComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Link">>) {
+  const p = element.props;
+
+  return (
+    <PdfLink
+      src={p.href}
+      style={{
+        fontSize: p.fontSize ?? 12,
+        color: p.color ?? "#2563eb",
+        textDecoration: "underline",
+      }}
+    >
+      {stripEmoji(p.text)}
+    </PdfLink>
+  );
+}
+
+// =============================================================================
+// Data Components
+// =============================================================================
+
+const tableStyles = StyleSheet.create({
+  table: {
+    width: "100%",
+  },
+  row: {
+    flexDirection: "row",
+  },
+  cell: {
+    padding: 6,
+  },
+  headerCell: {
+    padding: 6,
+    fontFamily: "Helvetica-Bold",
+  },
+  bottomBorder: {
+    borderBottomWidth: 1,
+  },
+});
+
+function TableComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Table">>) {
+  const p = element.props;
+
+  const borderColor = p.borderColor ?? "#e5e7eb";
+  const fontSize = p.fontSize ?? 10;
+  const defaultWidth = `${100 / p.columns.length}%`;
+
+  return (
+    <View style={tableStyles.table}>
+      {/* Header */}
+      <View
+        style={[
+          tableStyles.row,
+          tableStyles.bottomBorder,
+          {
+            borderBottomColor: borderColor,
+            backgroundColor: p.headerBackgroundColor ?? "#f3f4f6",
+          },
+        ]}
+      >
+        {p.columns.map((col, i) => (
+          <View
+            key={i}
+            style={[
+              tableStyles.headerCell,
+              { width: col.width ?? defaultWidth },
+            ]}
+          >
+            <PdfText
+              style={{
+                fontSize,
+                color: p.headerTextColor ?? "#111827",
+                textAlign: col.align ?? "left",
+              }}
+            >
+              {stripEmoji(col.header)}
+            </PdfText>
+          </View>
+        ))}
+      </View>
+
+      {/* Rows */}
+      {p.rows.map((row, rowIndex) => (
+        <View
+          key={rowIndex}
+          style={[
+            tableStyles.row,
+            tableStyles.bottomBorder,
+            {
+              borderBottomColor: borderColor,
+              backgroundColor:
+                p.striped && rowIndex % 2 === 1 ? "#f9fafb" : undefined,
+            },
+          ]}
+        >
+          {p.columns.map((col, colIndex) => (
+            <View
+              key={colIndex}
+              style={[tableStyles.cell, { width: col.width ?? defaultWidth }]}
+            >
+              <PdfText
+                style={{
+                  fontSize,
+                  textAlign: col.align ?? "left",
+                }}
+              >
+                {stripEmoji(row[colIndex] ?? "")}
+              </PdfText>
+            </View>
+          ))}
+        </View>
+      ))}
+    </View>
+  );
+}
+
+function ListComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"List">>) {
+  const p = element.props;
+
+  const fontSize = p.fontSize ?? 12;
+  const spacing = p.spacing ?? 4;
+
+  return (
+    <View style={{ gap: spacing }}>
+      {p.items.map((item, index) => (
+        <View key={index} style={{ flexDirection: "row", gap: 6 }}>
+          <PdfText
+            style={{
+              fontSize,
+              color: p.color ?? undefined,
+              width: p.ordered ? 20 : 12,
+            }}
+          >
+            {p.ordered ? `${index + 1}.` : "\u2022"}
+          </PdfText>
+          <PdfText
+            style={{
+              fontSize,
+              color: p.color ?? undefined,
+              flex: 1,
+            }}
+          >
+            {stripEmoji(item)}
+          </PdfText>
+        </View>
+      ))}
+    </View>
+  );
+}
+
+// =============================================================================
+// Decorative Components
+// =============================================================================
+
+function DividerComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Divider">>) {
+  const p = element.props;
+
+  return (
+    <View
+      style={{
+        borderBottomWidth: p.thickness ?? 1,
+        borderBottomColor: p.color ?? "#e5e7eb",
+        marginTop: p.marginTop ?? 8,
+        marginBottom: p.marginBottom ?? 8,
+      }}
+    />
+  );
+}
+
+function SpacerComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"Spacer">>) {
+  const p = element.props;
+
+  return <View style={{ height: p.height ?? 20 }} />;
+}
+
+// =============================================================================
+// Page-Level Components
+// =============================================================================
+
+function PageNumberComponent({
+  element,
+}: ComponentRenderProps<StandardComponentProps<"PageNumber">>) {
+  const p = element.props;
+
+  const format = p.format ?? "{pageNumber} / {totalPages}";
+
+  return (
+    <PdfText
+      style={{
+        fontSize: p.fontSize ?? 10,
+        color: p.color ?? "#6b7280",
+        textAlign: p.align ?? "center",
+      }}
+      render={({ pageNumber, totalPages }) =>
+        stripEmoji(
+          format
+            .replace("{pageNumber}", String(pageNumber))
+            .replace("{totalPages}", String(totalPages)),
+        )
+      }
+      fixed
+    />
+  );
+}
+
+// =============================================================================
+// Registry
+// =============================================================================
+
+export const standardComponents: ComponentRegistry = {
+  Document: DocumentComponent,
+  Page: PageComponent,
+  View: ViewComponent,
+  Row: RowComponent,
+  Column: ColumnComponent,
+  Heading: HeadingComponent,
+  Text: TextComponent,
+  Image: ImageComponent,
+  Link: LinkComponent,
+  Table: TableComponent,
+  List: ListComponent,
+  Divider: DividerComponent,
+  Spacer: SpacerComponent,
+  PageNumber: PageNumberComponent,
+};

+ 283 - 0
packages/react-pdf/src/contexts/actions.tsx

@@ -0,0 +1,283 @@
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  type ReactNode,
+} from "react";
+import {
+  resolveAction,
+  executeAction,
+  type ActionBinding,
+  type ActionHandler,
+  type ActionConfirm,
+  type ResolvedAction,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+let idCounter = 0;
+function generateUniqueId(): string {
+  idCounter += 1;
+  return `${Date.now()}-${idCounter}`;
+}
+
+function deepResolveValue(
+  value: unknown,
+  get: (path: string) => unknown,
+): unknown {
+  if (value === null || value === undefined) return value;
+
+  if (value === "$id") {
+    return generateUniqueId();
+  }
+
+  if (typeof value === "object" && !Array.isArray(value)) {
+    const obj = value as Record<string, unknown>;
+    const keys = Object.keys(obj);
+
+    if (keys.length === 1 && typeof obj.$state === "string") {
+      return get(obj.$state as string);
+    }
+
+    if (keys.length === 1 && "$id" in obj) {
+      return generateUniqueId();
+    }
+  }
+
+  if (Array.isArray(value)) {
+    return value.map((item) => deepResolveValue(item, get));
+  }
+
+  if (typeof value === "object") {
+    const resolved: Record<string, unknown> = {};
+    for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
+      resolved[key] = deepResolveValue(val, get);
+    }
+    return resolved;
+  }
+
+  return value;
+}
+
+export interface PendingConfirmation {
+  action: ResolvedAction;
+  handler: ActionHandler;
+  resolve: () => void;
+  reject: () => void;
+}
+
+export interface ActionContextValue {
+  handlers: Record<string, ActionHandler>;
+  loadingActions: Set<string>;
+  pendingConfirmation: PendingConfirmation | null;
+  execute: (binding: ActionBinding) => Promise<void>;
+  confirm: () => void;
+  cancel: () => void;
+  registerHandler: (name: string, handler: ActionHandler) => void;
+}
+
+const ActionContext = createContext<ActionContextValue | null>(null);
+
+export interface ActionProviderProps {
+  handlers?: Record<string, ActionHandler>;
+  navigate?: (path: string) => void;
+  children: ReactNode;
+}
+
+export function ActionProvider({
+  handlers: initialHandlers = {},
+  navigate,
+  children,
+}: ActionProviderProps) {
+  const { state, get, set } = useStateStore();
+  const [handlers, setHandlers] =
+    useState<Record<string, ActionHandler>>(initialHandlers);
+  const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
+  const [pendingConfirmation, setPendingConfirmation] =
+    useState<PendingConfirmation | null>(null);
+
+  const registerHandler = useCallback(
+    (name: string, handler: ActionHandler) => {
+      setHandlers((prev) => ({ ...prev, [name]: handler }));
+    },
+    [],
+  );
+
+  const execute = useCallback(
+    async (binding: ActionBinding) => {
+      const resolved = resolveAction(binding, state);
+
+      if (resolved.action === "setState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const value = resolved.params.value;
+        if (statePath) {
+          set(statePath, value);
+        }
+        return;
+      }
+
+      if (resolved.action === "pushState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const rawValue = resolved.params.value;
+        if (statePath) {
+          const resolvedValue = deepResolveValue(rawValue, get);
+          const arr = (get(statePath) as unknown[] | undefined) ?? [];
+          set(statePath, [...arr, resolvedValue]);
+          const clearStatePath = resolved.params.clearStatePath as
+            | string
+            | undefined;
+          if (clearStatePath) {
+            set(clearStatePath, "");
+          }
+        }
+        return;
+      }
+
+      if (resolved.action === "removeState" && resolved.params) {
+        const statePath = resolved.params.statePath as string;
+        const index = resolved.params.index as number;
+        if (statePath !== undefined && index !== undefined) {
+          const arr = (get(statePath) as unknown[] | undefined) ?? [];
+          set(
+            statePath,
+            arr.filter((_, i) => i !== index),
+          );
+        }
+        return;
+      }
+
+      const handler = handlers[resolved.action];
+
+      if (!handler) {
+        console.warn(`No handler registered for action: ${resolved.action}`);
+        return;
+      }
+
+      if (resolved.confirm) {
+        return new Promise<void>((resolve, reject) => {
+          setPendingConfirmation({
+            action: resolved,
+            handler,
+            resolve: () => {
+              setPendingConfirmation(null);
+              resolve();
+            },
+            reject: () => {
+              setPendingConfirmation(null);
+              reject(new Error("Action cancelled"));
+            },
+          });
+        }).then(async () => {
+          setLoadingActions((prev) => new Set(prev).add(resolved.action));
+          try {
+            await executeAction({
+              action: resolved,
+              handler,
+              setState: set,
+              navigate,
+              executeAction: async (name) => {
+                const subBinding: ActionBinding = { action: name };
+                await execute(subBinding);
+              },
+            });
+          } finally {
+            setLoadingActions((prev) => {
+              const next = new Set(prev);
+              next.delete(resolved.action);
+              return next;
+            });
+          }
+        });
+      }
+
+      setLoadingActions((prev) => new Set(prev).add(resolved.action));
+      try {
+        await executeAction({
+          action: resolved,
+          handler,
+          setState: set,
+          navigate,
+          executeAction: async (name) => {
+            const subBinding: ActionBinding = { action: name };
+            await execute(subBinding);
+          },
+        });
+      } finally {
+        setLoadingActions((prev) => {
+          const next = new Set(prev);
+          next.delete(resolved.action);
+          return next;
+        });
+      }
+    },
+    [state, handlers, get, set, navigate],
+  );
+
+  const confirm = useCallback(() => {
+    pendingConfirmation?.resolve();
+  }, [pendingConfirmation]);
+
+  const cancel = useCallback(() => {
+    pendingConfirmation?.reject();
+  }, [pendingConfirmation]);
+
+  const value = useMemo<ActionContextValue>(
+    () => ({
+      handlers,
+      loadingActions,
+      pendingConfirmation,
+      execute,
+      confirm,
+      cancel,
+      registerHandler,
+    }),
+    [
+      handlers,
+      loadingActions,
+      pendingConfirmation,
+      execute,
+      confirm,
+      cancel,
+      registerHandler,
+    ],
+  );
+
+  return (
+    <ActionContext.Provider value={value}>{children}</ActionContext.Provider>
+  );
+}
+
+export function useActions(): ActionContextValue {
+  const ctx = useContext(ActionContext);
+  if (!ctx) {
+    throw new Error("useActions must be used within an ActionProvider");
+  }
+  return ctx;
+}
+
+export function useAction(binding: ActionBinding): {
+  execute: () => Promise<void>;
+  isLoading: boolean;
+} {
+  const { execute, loadingActions } = useActions();
+  const isLoading = loadingActions.has(binding.action);
+
+  const executeAction = useCallback(() => execute(binding), [execute, binding]);
+
+  return { execute: executeAction, isLoading };
+}
+
+export interface ConfirmDialogProps {
+  confirm: ActionConfirm;
+  onConfirm: () => void;
+  onCancel: () => void;
+}
+
+/**
+ * No-op confirm dialog for PDF context. PDFs are non-interactive,
+ * so confirmations are not rendered.
+ */
+export function ConfirmDialog(_props: ConfirmDialogProps) {
+  return null;
+}

+ 26 - 0
packages/react-pdf/src/contexts/repeat-scope.tsx

@@ -0,0 +1,26 @@
+import React, { createContext, useContext, type ReactNode } from "react";
+
+export interface RepeatScopeValue {
+  item: unknown;
+  index: number;
+  basePath: string;
+}
+
+const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
+
+export function RepeatScopeProvider({
+  item,
+  index,
+  basePath,
+  children,
+}: RepeatScopeValue & { children: ReactNode }) {
+  return (
+    <RepeatScopeContext.Provider value={{ item, index, basePath }}>
+      {children}
+    </RepeatScopeContext.Provider>
+  );
+}
+
+export function useRepeatScope(): RepeatScopeValue | null {
+  return useContext(RepeatScopeContext);
+}

+ 117 - 0
packages/react-pdf/src/contexts/state.tsx

@@ -0,0 +1,117 @@
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  useEffect,
+  useRef,
+  type ReactNode,
+} from "react";
+import { getByPath, setByPath, type StateModel } from "@json-render/core";
+
+export interface StateContextValue {
+  state: StateModel;
+  get: (path: string) => unknown;
+  set: (path: string, value: unknown) => void;
+  update: (updates: Record<string, unknown>) => void;
+}
+
+const StateContext = createContext<StateContextValue | null>(null);
+
+export interface StateProviderProps {
+  initialState?: StateModel;
+  onStateChange?: (path: string, value: unknown) => void;
+  children: ReactNode;
+}
+
+export function StateProvider({
+  initialState = {},
+  onStateChange,
+  children,
+}: StateProviderProps) {
+  const [state, setStateInternal] = useState<StateModel>(initialState);
+
+  const stateRef = useRef(state);
+  stateRef.current = state;
+
+  const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
+
+  useEffect(() => {
+    const newJson = JSON.stringify(initialState);
+    if (newJson !== initialStateJsonRef.current) {
+      initialStateJsonRef.current = newJson;
+      if (initialState && Object.keys(initialState).length > 0) {
+        setStateInternal((prev) => ({ ...prev, ...initialState }));
+      }
+    }
+  }, [initialState]);
+
+  const get = useCallback(
+    (path: string) => getByPath(stateRef.current, path),
+    [],
+  );
+
+  const set = useCallback(
+    (path: string, value: unknown) => {
+      setStateInternal((prev) => {
+        const next = { ...prev };
+        setByPath(next, path, value);
+        return next;
+      });
+      onStateChange?.(path, value);
+    },
+    [onStateChange],
+  );
+
+  const update = useCallback(
+    (updates: Record<string, unknown>) => {
+      const entries = Object.entries(updates);
+      setStateInternal((prev) => {
+        const next = { ...prev };
+        for (const [path, value] of entries) {
+          setByPath(next, path, value);
+        }
+        return next;
+      });
+      for (const [path, value] of entries) {
+        onStateChange?.(path, value);
+      }
+    },
+    [onStateChange],
+  );
+
+  const value = useMemo<StateContextValue>(
+    () => ({ state, get, set, update }),
+    [state, get, set, update],
+  );
+
+  return (
+    <StateContext.Provider value={value}>{children}</StateContext.Provider>
+  );
+}
+
+export function useStateStore(): StateContextValue {
+  const ctx = useContext(StateContext);
+  if (!ctx) {
+    throw new Error("useStateStore must be used within a StateProvider");
+  }
+  return ctx;
+}
+
+export function useStateValue<T>(path: string): T | undefined {
+  const { state } = useStateStore();
+  return getByPath(state, path) as T | undefined;
+}
+
+export function useStateBinding<T>(
+  path: string,
+): [T | undefined, (value: T) => void] {
+  const { state, set } = useStateStore();
+  const value = getByPath(state, path) as T | undefined;
+  const setValue = useCallback(
+    (newValue: T) => set(path, newValue),
+    [path, set],
+  );
+  return [value, setValue];
+}

+ 262 - 0
packages/react-pdf/src/contexts/validation.tsx

@@ -0,0 +1,262 @@
+import React, {
+  createContext,
+  useContext,
+  useState,
+  useCallback,
+  useMemo,
+  type ReactNode,
+} from "react";
+import {
+  runValidation,
+  type ValidationConfig,
+  type ValidationFunction,
+  type ValidationResult,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+export interface FieldValidationState {
+  touched: boolean;
+  validated: boolean;
+  result: ValidationResult | null;
+}
+
+export interface ValidationContextValue {
+  customFunctions: Record<string, ValidationFunction>;
+  fieldStates: Record<string, FieldValidationState>;
+  validate: (path: string, config: ValidationConfig) => ValidationResult;
+  touch: (path: string) => void;
+  clear: (path: string) => void;
+  validateAll: () => boolean;
+  registerField: (path: string, config: ValidationConfig) => void;
+}
+
+const ValidationContext = createContext<ValidationContextValue | null>(null);
+
+export interface ValidationProviderProps {
+  customFunctions?: Record<string, ValidationFunction>;
+  children: ReactNode;
+}
+
+function dynamicArgsEqual(
+  a: Record<string, unknown> | undefined,
+  b: Record<string, unknown> | undefined,
+): boolean {
+  if (a === b) return true;
+  if (!a || !b) return false;
+
+  const keysA = Object.keys(a);
+  const keysB = Object.keys(b);
+  if (keysA.length !== keysB.length) return false;
+
+  for (const key of keysA) {
+    const va = a[key];
+    const vb = b[key];
+    if (va === vb) continue;
+    if (
+      typeof va === "object" &&
+      va !== null &&
+      typeof vb === "object" &&
+      vb !== null
+    ) {
+      const sa = (va as Record<string, unknown>).$state;
+      const sb = (vb as Record<string, unknown>).$state;
+      if (typeof sa === "string" && sa === sb) continue;
+    }
+    return false;
+  }
+  return true;
+}
+
+function validationConfigEqual(
+  a: ValidationConfig,
+  b: ValidationConfig,
+): boolean {
+  if (a === b) return true;
+  if (a.validateOn !== b.validateOn) return false;
+
+  const ac = a.checks ?? [];
+  const bc = b.checks ?? [];
+  if (ac.length !== bc.length) return false;
+
+  for (let i = 0; i < ac.length; i++) {
+    const ca = ac[i]!;
+    const cb = bc[i]!;
+    if (ca.type !== cb.type) return false;
+    if (ca.message !== cb.message) return false;
+    if (!dynamicArgsEqual(ca.args, cb.args)) return false;
+  }
+
+  return true;
+}
+
+export function ValidationProvider({
+  customFunctions = {},
+  children,
+}: ValidationProviderProps) {
+  const { state } = useStateStore();
+  const [fieldStates, setFieldStates] = useState<
+    Record<string, FieldValidationState>
+  >({});
+  const [fieldConfigs, setFieldConfigs] = useState<
+    Record<string, ValidationConfig>
+  >({});
+
+  const registerField = useCallback(
+    (path: string, config: ValidationConfig) => {
+      setFieldConfigs((prev) => {
+        const existing = prev[path];
+        if (existing && validationConfigEqual(existing, config)) {
+          return prev;
+        }
+        return { ...prev, [path]: config };
+      });
+    },
+    [],
+  );
+
+  const validate = useCallback(
+    (path: string, config: ValidationConfig): ValidationResult => {
+      const segments = path.split("/").filter(Boolean);
+      let value: unknown = state;
+      for (const seg of segments) {
+        if (value != null && typeof value === "object") {
+          value = (value as Record<string, unknown>)[seg];
+        } else {
+          value = undefined;
+          break;
+        }
+      }
+      const result = runValidation(config, {
+        value,
+        stateModel: state,
+        customFunctions,
+      });
+
+      setFieldStates((prev) => ({
+        ...prev,
+        [path]: {
+          touched: prev[path]?.touched ?? true,
+          validated: true,
+          result,
+        },
+      }));
+
+      return result;
+    },
+    [state, customFunctions],
+  );
+
+  const touch = useCallback((path: string) => {
+    setFieldStates((prev) => ({
+      ...prev,
+      [path]: {
+        ...prev[path],
+        touched: true,
+        validated: prev[path]?.validated ?? false,
+        result: prev[path]?.result ?? null,
+      },
+    }));
+  }, []);
+
+  const clear = useCallback((path: string) => {
+    setFieldStates((prev) => {
+      const { [path]: _, ...rest } = prev;
+      return rest;
+    });
+  }, []);
+
+  const validateAll = useCallback(() => {
+    let allValid = true;
+    for (const [path, config] of Object.entries(fieldConfigs)) {
+      const result = validate(path, config);
+      if (!result.valid) {
+        allValid = false;
+      }
+    }
+    return allValid;
+  }, [fieldConfigs, validate]);
+
+  const value = useMemo<ValidationContextValue>(
+    () => ({
+      customFunctions,
+      fieldStates,
+      validate,
+      touch,
+      clear,
+      validateAll,
+      registerField,
+    }),
+    [
+      customFunctions,
+      fieldStates,
+      validate,
+      touch,
+      clear,
+      validateAll,
+      registerField,
+    ],
+  );
+
+  return (
+    <ValidationContext.Provider value={value}>
+      {children}
+    </ValidationContext.Provider>
+  );
+}
+
+export function useValidation(): ValidationContextValue {
+  const ctx = useContext(ValidationContext);
+  if (!ctx) {
+    throw new Error("useValidation must be used within a ValidationProvider");
+  }
+  return ctx;
+}
+
+export function useFieldValidation(
+  path: string,
+  config?: ValidationConfig,
+): {
+  state: FieldValidationState;
+  validate: () => ValidationResult;
+  touch: () => void;
+  clear: () => void;
+  errors: string[];
+  isValid: boolean;
+} {
+  const {
+    fieldStates,
+    validate: validateField,
+    touch: touchField,
+    clear: clearField,
+    registerField,
+  } = useValidation();
+
+  React.useEffect(() => {
+    if (config) {
+      registerField(path, config);
+    }
+  }, [path, config, registerField]);
+
+  const state = fieldStates[path] ?? {
+    touched: false,
+    validated: false,
+    result: null,
+  };
+
+  const validate = useCallback(
+    () => validateField(path, config ?? { checks: [] }),
+    [path, config, validateField],
+  );
+
+  const touch = useCallback(() => touchField(path), [path, touchField]);
+  const clear = useCallback(() => clearField(path), [path, clearField]);
+
+  return {
+    state,
+    validate,
+    touch,
+    clear,
+    errors: state.result?.errors ?? [],
+    isValid: state.result?.valid ?? true,
+  };
+}

+ 64 - 0
packages/react-pdf/src/contexts/visibility.tsx

@@ -0,0 +1,64 @@
+import React, {
+  createContext,
+  useContext,
+  useMemo,
+  type ReactNode,
+} from "react";
+import {
+  evaluateVisibility,
+  type VisibilityCondition,
+  type VisibilityContext as CoreVisibilityContext,
+} from "@json-render/core";
+import { useStateStore } from "./state";
+
+export interface VisibilityContextValue {
+  isVisible: (condition: VisibilityCondition | undefined) => boolean;
+  ctx: CoreVisibilityContext;
+}
+
+const VisibilityContext = createContext<VisibilityContextValue | null>(null);
+
+export interface VisibilityProviderProps {
+  children: ReactNode;
+}
+
+export function VisibilityProvider({ children }: VisibilityProviderProps) {
+  const { state } = useStateStore();
+
+  const ctx: CoreVisibilityContext = useMemo(
+    () => ({ stateModel: state }),
+    [state],
+  );
+
+  const isVisible = useMemo(
+    () => (condition: VisibilityCondition | undefined) =>
+      evaluateVisibility(condition, ctx),
+    [ctx],
+  );
+
+  const value = useMemo<VisibilityContextValue>(
+    () => ({ isVisible, ctx }),
+    [isVisible, ctx],
+  );
+
+  return (
+    <VisibilityContext.Provider value={value}>
+      {children}
+    </VisibilityContext.Provider>
+  );
+}
+
+export function useVisibility(): VisibilityContextValue {
+  const ctx = useContext(VisibilityContext);
+  if (!ctx) {
+    throw new Error("useVisibility must be used within a VisibilityProvider");
+  }
+  return ctx;
+}
+
+export function useIsVisible(
+  condition: VisibilityCondition | undefined,
+): boolean {
+  const { isVisible } = useVisibility();
+  return isVisible(condition);
+}

+ 92 - 0
packages/react-pdf/src/index.ts

@@ -0,0 +1,92 @@
+// Schema
+export { schema, type ReactPdfSchema, type ReactPdfSpec } from "./schema";
+
+// Core types (re-exported for convenience)
+export type { Spec } from "@json-render/core";
+
+// Catalog-aware types
+export type {
+  SetState,
+  StateModel,
+  ComponentContext,
+  ComponentFn,
+  Components,
+} from "./catalog-types";
+
+// Contexts
+export {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+  type StateContextValue,
+  type StateProviderProps,
+} from "./contexts/state";
+
+export {
+  VisibilityProvider,
+  useVisibility,
+  useIsVisible,
+  type VisibilityContextValue,
+  type VisibilityProviderProps,
+} from "./contexts/visibility";
+
+export {
+  ActionProvider,
+  useActions,
+  useAction,
+  ConfirmDialog,
+  type ActionContextValue,
+  type ActionProviderProps,
+  type PendingConfirmation,
+  type ConfirmDialogProps,
+} from "./contexts/actions";
+
+export {
+  ValidationProvider,
+  useValidation,
+  useFieldValidation,
+  type ValidationContextValue,
+  type ValidationProviderProps,
+  type FieldValidationState,
+} from "./contexts/validation";
+
+export {
+  RepeatScopeProvider,
+  useRepeatScope,
+  type RepeatScopeValue,
+} from "./contexts/repeat-scope";
+
+// Renderer
+export {
+  defineRegistry,
+  type DefineRegistryResult,
+  createRenderer,
+  type CreateRendererProps,
+  type ComponentMap,
+  Renderer,
+  JSONUIProvider,
+  type ComponentRenderProps,
+  type ComponentRenderer,
+  type ComponentRegistry,
+  type RendererProps,
+  type JSONUIProviderProps,
+} from "./renderer";
+
+// Standard components
+export { standardComponents } from "./components";
+
+// Server-side render functions
+export {
+  renderToBuffer,
+  renderToStream,
+  renderToFile,
+  type RenderOptions,
+} from "./render";
+
+// Catalog definitions
+export {
+  standardComponentDefinitions,
+  type StandardComponentDefinitions,
+  type StandardComponentProps,
+} from "./catalog";

+ 180 - 0
packages/react-pdf/src/render.tsx

@@ -0,0 +1,180 @@
+import React from "react";
+import {
+  renderToBuffer as pdfRenderToBuffer,
+  renderToStream as pdfRenderToStream,
+  render as pdfRender,
+} from "@react-pdf/renderer";
+import type { Spec, UIElement } from "@json-render/core";
+import {
+  resolveElementProps,
+  evaluateVisibility,
+  getByPath,
+  type PropResolutionContext,
+} from "@json-render/core";
+import { standardComponents } from "./components/standard";
+
+// Re-export the standard components for use in custom registries
+export { standardComponents };
+
+export type RenderComponentRegistry = Record<string, React.ComponentType<any>>;
+
+export interface RenderOptions {
+  registry?: RenderComponentRegistry;
+  includeStandard?: boolean;
+  state?: Record<string, unknown>;
+}
+
+const noopEmit = () => {};
+
+function renderElement(
+  elementKey: string,
+  spec: Spec,
+  registry: RenderComponentRegistry,
+  stateModel: Record<string, unknown>,
+  repeatItem?: unknown,
+  repeatIndex?: number,
+  repeatBasePath?: string,
+): React.ReactElement | null {
+  const element = spec.elements[elementKey];
+  if (!element) return null;
+
+  const ctx: PropResolutionContext = {
+    stateModel,
+    repeatItem,
+    repeatIndex,
+    repeatBasePath,
+  };
+
+  if (element.visible !== undefined) {
+    if (!evaluateVisibility(element.visible, ctx)) {
+      return null;
+    }
+  }
+
+  const resolvedProps = resolveElementProps(
+    element.props as Record<string, unknown>,
+    ctx,
+  );
+  const resolvedElement: UIElement = { ...element, props: resolvedProps };
+
+  const Component = registry[resolvedElement.type];
+  if (!Component) return null;
+
+  if (resolvedElement.repeat) {
+    const items =
+      (getByPath(stateModel, resolvedElement.repeat.statePath) as
+        | unknown[]
+        | undefined) ?? [];
+
+    const fragments = items.map((item, index) => {
+      const key =
+        resolvedElement.repeat!.key && typeof item === "object" && item !== null
+          ? String(
+              (item as Record<string, unknown>)[resolvedElement.repeat!.key!] ??
+                index,
+            )
+          : String(index);
+
+      const childPath = `${resolvedElement.repeat!.statePath}/${index}`;
+      const children = resolvedElement.children?.map((childKey) =>
+        renderElement(
+          childKey,
+          spec,
+          registry,
+          stateModel,
+          item,
+          index,
+          childPath,
+        ),
+      );
+
+      return (
+        <Component key={key} element={resolvedElement} emit={noopEmit}>
+          {children}
+        </Component>
+      );
+    });
+
+    return <>{fragments}</>;
+  }
+
+  const children = resolvedElement.children?.map((childKey) =>
+    renderElement(
+      childKey,
+      spec,
+      registry,
+      stateModel,
+      repeatItem,
+      repeatIndex,
+      repeatBasePath,
+    ),
+  );
+
+  return (
+    <Component key={elementKey} element={resolvedElement} emit={noopEmit}>
+      {children && children.length > 0 ? children : undefined}
+    </Component>
+  );
+}
+
+function buildDocument(
+  spec: Spec,
+  options: RenderOptions = {},
+): React.ReactElement {
+  const {
+    registry: customRegistry,
+    includeStandard = true,
+    state = {},
+  } = options;
+
+  const mergedState: Record<string, unknown> = {
+    ...spec.state,
+    ...state,
+  };
+
+  const registry: RenderComponentRegistry = {
+    ...(includeStandard ? standardComponents : {}),
+    ...customRegistry,
+  };
+
+  const root = renderElement(spec.root, spec, registry, mergedState);
+  return root ?? <></>;
+}
+
+/**
+ * Render a json-render spec to a PDF buffer.
+ *
+ * This is a standalone server-side function that resolves the spec tree
+ * without React hooks or contexts, making it safe to import in Next.js
+ * route handlers and other server-only environments.
+ */
+export async function renderToBuffer(
+  spec: Spec,
+  options?: RenderOptions,
+): Promise<Uint8Array> {
+  const document = buildDocument(spec, options);
+  return pdfRenderToBuffer(document as any);
+}
+
+/**
+ * Render a json-render spec to a PDF readable stream.
+ */
+export async function renderToStream(
+  spec: Spec,
+  options?: RenderOptions,
+): Promise<ReadableStream> {
+  const document = buildDocument(spec, options);
+  return pdfRenderToStream(document as any);
+}
+
+/**
+ * Render a json-render spec to a PDF file on disk.
+ */
+export async function renderToFile(
+  spec: Spec,
+  filePath: string,
+  options?: RenderOptions,
+): Promise<void> {
+  const document = buildDocument(spec, options);
+  await pdfRender(document as any, filePath);
+}

+ 503 - 0
packages/react-pdf/src/renderer.tsx

@@ -0,0 +1,503 @@
+import React, {
+  type ComponentType,
+  type ErrorInfo,
+  type ReactNode,
+  useCallback,
+  useMemo,
+} from "react";
+import type {
+  UIElement,
+  Spec,
+  ActionBinding,
+  Catalog,
+  SchemaDefinition,
+} from "@json-render/core";
+import {
+  resolveElementProps,
+  resolveBindings,
+  resolveActionParam,
+  evaluateVisibility,
+  getByPath,
+  type PropResolutionContext,
+  type VisibilityContext as CoreVisibilityContext,
+} from "@json-render/core";
+import type { Components, SetState, StateModel } from "./catalog-types";
+import { useIsVisible, useVisibility } from "./contexts/visibility";
+import { useActions } from "./contexts/actions";
+import { useStateStore } from "./contexts/state";
+import { StateProvider } from "./contexts/state";
+import { VisibilityProvider } from "./contexts/visibility";
+import { ActionProvider } from "./contexts/actions";
+import { ValidationProvider } from "./contexts/validation";
+import { standardComponents } from "./components/standard";
+import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
+
+// =============================================================================
+// Types
+// =============================================================================
+
+export interface ComponentRenderProps<P = Record<string, unknown>> {
+  element: UIElement<string, P>;
+  children?: ReactNode;
+  emit: (event: string) => void;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}
+
+export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
+  ComponentRenderProps<P>
+>;
+
+export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
+
+export interface RendererProps {
+  spec: Spec | null;
+  registry?: ComponentRegistry;
+  includeStandard?: boolean;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+
+// =============================================================================
+// ElementErrorBoundary
+// =============================================================================
+
+interface ElementErrorBoundaryProps {
+  elementType: string;
+  children: ReactNode;
+}
+
+interface ElementErrorBoundaryState {
+  hasError: boolean;
+}
+
+class ElementErrorBoundary extends React.Component<
+  ElementErrorBoundaryProps,
+  ElementErrorBoundaryState
+> {
+  constructor(props: ElementErrorBoundaryProps) {
+    super(props);
+    this.state = { hasError: false };
+  }
+
+  static getDerivedStateFromError(): ElementErrorBoundaryState {
+    return { hasError: true };
+  }
+
+  componentDidCatch(error: Error, info: ErrorInfo) {
+    console.error(
+      `[json-render/react-pdf] Rendering error in <${this.props.elementType}>:`,
+      error,
+      info.componentStack,
+    );
+  }
+
+  render() {
+    if (this.state.hasError) {
+      return null;
+    }
+    return this.props.children;
+  }
+}
+
+// =============================================================================
+// ElementRenderer
+// =============================================================================
+
+interface ElementRendererProps {
+  element: UIElement;
+  spec: Spec;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+
+const ElementRenderer = React.memo(function ElementRenderer({
+  element,
+  spec,
+  registry,
+  loading,
+  fallback,
+}: ElementRendererProps) {
+  const repeatScope = useRepeatScope();
+  const { ctx } = useVisibility();
+  const { execute } = useActions();
+
+  const fullCtx: PropResolutionContext = useMemo(
+    () =>
+      repeatScope
+        ? {
+            ...ctx,
+            repeatItem: repeatScope.item,
+            repeatIndex: repeatScope.index,
+            repeatBasePath: repeatScope.basePath,
+          }
+        : ctx,
+    [ctx, repeatScope],
+  );
+
+  const isVisible =
+    element.visible === undefined
+      ? true
+      : evaluateVisibility(element.visible, fullCtx);
+
+  const onBindings = element.on;
+  const emit = useCallback(
+    (eventName: string) => {
+      const binding = onBindings?.[eventName];
+      if (!binding) return;
+      const actionBindings = Array.isArray(binding) ? binding : [binding];
+      for (const b of actionBindings) {
+        if (!b.params) {
+          execute(b);
+          continue;
+        }
+        const resolved: Record<string, unknown> = {};
+        for (const [key, val] of Object.entries(b.params)) {
+          resolved[key] = resolveActionParam(val, fullCtx);
+        }
+        execute({ ...b, params: resolved });
+      }
+    },
+    [onBindings, execute, fullCtx],
+  );
+
+  if (!isVisible) {
+    return null;
+  }
+
+  const rawProps = element.props as Record<string, unknown>;
+  const elementBindings = resolveBindings(rawProps, fullCtx);
+  const resolvedProps = resolveElementProps(rawProps, fullCtx);
+
+  const resolvedElement =
+    resolvedProps !== element.props
+      ? { ...element, props: resolvedProps }
+      : element;
+
+  const Component = registry[resolvedElement.type] ?? fallback;
+
+  if (!Component) {
+    console.warn(
+      `[json-render/react-pdf] No renderer for component type: ${resolvedElement.type}`,
+    );
+    return null;
+  }
+
+  const children = resolvedElement.repeat ? (
+    <RepeatChildren
+      element={resolvedElement}
+      spec={spec}
+      registry={registry}
+      loading={loading}
+      fallback={fallback}
+    />
+  ) : (
+    resolvedElement.children?.map((childKey) => {
+      const childElement = spec.elements[childKey];
+      if (!childElement) {
+        if (!loading) {
+          console.warn(
+            `[json-render/react-pdf] Missing element "${childKey}" referenced as child of "${resolvedElement.type}".`,
+          );
+        }
+        return null;
+      }
+      return (
+        <ElementRenderer
+          key={childKey}
+          element={childElement}
+          spec={spec}
+          registry={registry}
+          loading={loading}
+          fallback={fallback}
+        />
+      );
+    })
+  );
+
+  return (
+    <ElementErrorBoundary elementType={resolvedElement.type}>
+      <Component
+        element={resolvedElement}
+        emit={emit}
+        bindings={elementBindings}
+        loading={loading}
+      >
+        {children}
+      </Component>
+    </ElementErrorBoundary>
+  );
+});
+
+// =============================================================================
+// RepeatChildren
+// =============================================================================
+
+function RepeatChildren({
+  element,
+  spec,
+  registry,
+  loading,
+  fallback,
+}: {
+  element: UIElement;
+  spec: Spec;
+  registry: ComponentRegistry;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}) {
+  const { state } = useStateStore();
+  const repeat = element.repeat!;
+  const statePath = repeat.statePath;
+
+  const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
+
+  return (
+    <>
+      {items.map((itemValue, index) => {
+        const key =
+          repeat.key && typeof itemValue === "object" && itemValue !== null
+            ? String(
+                (itemValue as Record<string, unknown>)[repeat.key] ?? index,
+              )
+            : String(index);
+
+        return (
+          <RepeatScopeProvider
+            key={key}
+            item={itemValue}
+            index={index}
+            basePath={`${statePath}/${index}`}
+          >
+            {element.children?.map((childKey) => {
+              const childElement = spec.elements[childKey];
+              if (!childElement) {
+                if (!loading) {
+                  console.warn(
+                    `[json-render/react-pdf] Missing element "${childKey}" referenced as child of "${element.type}" (repeat).`,
+                  );
+                }
+                return null;
+              }
+              return (
+                <ElementRenderer
+                  key={childKey}
+                  element={childElement}
+                  spec={spec}
+                  registry={registry}
+                  loading={loading}
+                  fallback={fallback}
+                />
+              );
+            })}
+          </RepeatScopeProvider>
+        );
+      })}
+    </>
+  );
+}
+
+// =============================================================================
+// Renderer
+// =============================================================================
+
+export function Renderer({
+  spec,
+  registry: customRegistry,
+  includeStandard = true,
+  loading,
+  fallback,
+}: RendererProps) {
+  const registry: ComponentRegistry = useMemo(
+    () => ({
+      ...(includeStandard ? standardComponents : {}),
+      ...customRegistry,
+    }),
+    [customRegistry, includeStandard],
+  );
+
+  if (!spec || !spec.root) {
+    return null;
+  }
+
+  const rootElement = spec.elements[spec.root];
+  if (!rootElement) {
+    return null;
+  }
+
+  return (
+    <ElementRenderer
+      element={rootElement}
+      spec={spec}
+      registry={registry}
+      loading={loading}
+      fallback={fallback}
+    />
+  );
+}
+
+// =============================================================================
+// JSONUIProvider
+// =============================================================================
+
+export interface JSONUIProviderProps {
+  registry?: ComponentRegistry;
+  initialState?: Record<string, unknown>;
+  handlers?: Record<
+    string,
+    (params: Record<string, unknown>) => Promise<unknown> | unknown
+  >;
+  navigate?: (path: string) => void;
+  validationFunctions?: Record<
+    string,
+    (value: unknown, args?: Record<string, unknown>) => boolean
+  >;
+  onStateChange?: (path: string, value: unknown) => void;
+  children: ReactNode;
+}
+
+export function JSONUIProvider({
+  initialState,
+  handlers,
+  navigate,
+  validationFunctions,
+  onStateChange,
+  children,
+}: JSONUIProviderProps) {
+  return (
+    <StateProvider initialState={initialState} onStateChange={onStateChange}>
+      <VisibilityProvider>
+        <ActionProvider handlers={handlers} navigate={navigate}>
+          <ValidationProvider customFunctions={validationFunctions}>
+            {children}
+          </ValidationProvider>
+        </ActionProvider>
+      </VisibilityProvider>
+    </StateProvider>
+  );
+}
+
+// =============================================================================
+// defineRegistry
+// =============================================================================
+
+export interface DefineRegistryResult {
+  registry: ComponentRegistry;
+}
+
+type DefineRegistryComponentFn = (ctx: {
+  props: unknown;
+  children?: React.ReactNode;
+  emit: (event: string) => void;
+  bindings?: Record<string, string>;
+  loading?: boolean;
+}) => React.ReactNode;
+
+export function defineRegistry<C extends Catalog>(
+  _catalog: C,
+  options: {
+    components?: Components<C>;
+  },
+): DefineRegistryResult {
+  const registry: ComponentRegistry = {};
+  if (options.components) {
+    for (const [name, componentFn] of Object.entries(options.components)) {
+      registry[name] = ({
+        element,
+        children,
+        emit,
+        bindings,
+        loading,
+      }: ComponentRenderProps) => {
+        return (componentFn as DefineRegistryComponentFn)({
+          props: element.props,
+          children,
+          emit,
+          bindings,
+          loading,
+        });
+      };
+    }
+  }
+
+  return { registry };
+}
+
+// =============================================================================
+// createRenderer
+// =============================================================================
+
+export interface CreateRendererProps {
+  spec: Spec | null;
+  state?: Record<string, unknown>;
+  onAction?: (actionName: string, params?: Record<string, unknown>) => void;
+  onStateChange?: (path: string, value: unknown) => void;
+  loading?: boolean;
+  fallback?: ComponentRenderer;
+}
+
+export type ComponentMap<
+  TComponents extends Record<string, { props: unknown }>,
+> = {
+  [K in keyof TComponents]: ComponentType<
+    ComponentRenderProps<
+      TComponents[K]["props"] extends { _output: infer O }
+        ? O
+        : Record<string, unknown>
+    >
+  >;
+};
+
+export function createRenderer<
+  TDef extends SchemaDefinition,
+  TCatalog extends { components: Record<string, { props: unknown }> },
+>(
+  catalog: Catalog<TDef, TCatalog>,
+  components: ComponentMap<TCatalog["components"]>,
+): ComponentType<CreateRendererProps> {
+  const registry: ComponentRegistry =
+    components as unknown as ComponentRegistry;
+
+  return function CatalogRenderer({
+    spec,
+    state,
+    onAction,
+    onStateChange,
+    loading,
+    fallback,
+  }: CreateRendererProps) {
+    const actionHandlers = onAction
+      ? new Proxy(
+          {} as Record<
+            string,
+            (params: Record<string, unknown>) => void | Promise<void>
+          >,
+          {
+            get: (_target, prop: string) => {
+              return (params: Record<string, unknown>) =>
+                onAction(prop, params);
+            },
+            has: () => true,
+          },
+        )
+      : undefined;
+
+    return (
+      <StateProvider initialState={state} onStateChange={onStateChange}>
+        <VisibilityProvider>
+          <ActionProvider handlers={actionHandlers}>
+            <ValidationProvider>
+              <Renderer
+                spec={spec}
+                registry={registry}
+                loading={loading}
+                fallback={fallback}
+              />
+            </ValidationProvider>
+          </ActionProvider>
+        </VisibilityProvider>
+      </StateProvider>
+    );
+  };
+}

+ 57 - 0
packages/react-pdf/src/schema.ts

@@ -0,0 +1,57 @@
+import { defineSchema } from "@json-render/core";
+
+/**
+ * The schema for @json-render/react-pdf
+ *
+ * Defines:
+ * - Spec: A flat tree of elements with keys, types, props, and children references
+ * - Catalog: Components with props schemas
+ *
+ * Reuses the same { root, elements } spec format as the React and React Native renderers.
+ */
+export const schema = defineSchema(
+  (s) => ({
+    spec: s.object({
+      root: s.string(),
+      elements: s.record(
+        s.object({
+          type: s.ref("catalog.components"),
+          props: s.propsOf("catalog.components"),
+          children: s.array(s.string()),
+          visible: s.any(),
+        }),
+      ),
+    }),
+
+    catalog: s.object({
+      components: s.map({
+        props: s.zod(),
+        slots: s.array(s.string()),
+        description: s.string(),
+        example: s.any(),
+      }),
+    }),
+  }),
+  {
+    defaultRules: [
+      "The root element MUST be a Document component. Its children MUST be Page components.",
+      "Every Page must specify a size (e.g. 'A4', 'LETTER') and can set orientation, margins, and background color.",
+      "Use Row for horizontal layouts and Column for vertical layouts. Both support gap, align, and justify props.",
+      "Table columns must define header and optionally width and align. Rows is an array of string arrays matching the column count.",
+      "All text content must use Heading or Text components. Raw strings are not supported.",
+      "Image src must be a fully qualified URL. For placeholder or stock images, always use https://picsum.photos/{width}/{height}?random={n} (e.g. https://picsum.photos/400/300?random=1). Never use unsplash URLs directly.",
+      "PageNumber renders the current page number and total pages. Place it inside a Page.",
+      "NEVER use emoji characters in any text content. The PDF font (Helvetica) does not support emojis and they will render as garbled/overlapping characters. Use plain text descriptions instead (e.g. 'Phone:' not '📞', 'Email:' not '📧').",
+      "PAGE LAYOUT: Be conservative with content density. A portrait A4/LETTER page with 40pt margins fits roughly 700pt of content height. For single-page documents (flyers, posters, one-pagers), keep all content on one page using smaller font sizes (10-11), tighter gaps (4-8), less padding (10-15), and smaller images (max height 200). For multi-page documents (resumes, reports), pack content densely to avoid large blank areas at the bottom of pages. Use small margins (marginTop: 30, marginBottom: 20), tight spacing (gap: 4-6), and compact font sizes (9-11 for body text) so pages are well-filled. It is better to fit more content on fewer pages than to spread thin content across many pages.",
+      "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.",
+    ],
+  },
+);
+
+export type ReactPdfSchema = typeof schema;
+
+export type ReactPdfSpec<TCatalog> = typeof schema extends {
+  createCatalog: (catalog: TCatalog) => { _specType: infer S };
+}
+  ? S
+  : never;

+ 20 - 0
packages/react-pdf/src/server.ts

@@ -0,0 +1,20 @@
+// Server-safe entry point: schema and catalog definitions only.
+// Does not import React or @react-pdf/renderer.
+
+export { schema, type ReactPdfSchema, type ReactPdfSpec } from "./schema";
+
+export {
+  standardComponentDefinitions,
+  type StandardComponentDefinitions,
+  type StandardComponentProps,
+} from "./catalog";
+
+export type { Spec } from "@json-render/core";
+
+export type {
+  SetState,
+  StateModel,
+  ComponentContext,
+  ComponentFn,
+  Components,
+} from "./catalog-types";

+ 9 - 0
packages/react-pdf/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@repo/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/react-pdf/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ["react", "@json-render/core", "@react-pdf/renderer", "zod"],
+});

Diferenças do arquivo suprimidas por serem muito extensas
+ 558 - 2
pnpm-lock.yaml


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff