Chris Tate 5 månader sedan
förälder
incheckning
cfdcdb410a

+ 28 - 9
apps/web/app/api/generate/route.ts

@@ -5,12 +5,12 @@ export const maxDuration = 30;
 
 const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
 
-AVAILABLE COMPONENTS (20):
+AVAILABLE COMPONENTS (22):
 
 Layout:
-- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card. Has children. Use maxWidth:"sm" + centered:true for login/signup forms.
+- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card for content sections. Has children. Use for forms/content boxes, NOT for page headers.
 - Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg" } - Flex container. Has children.
-- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children.
+- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children. ALWAYS use mobile-first: set columns:1 and use className for larger screens.
 - Divider: {} - Horizontal separator line
 
 Form Inputs:
@@ -22,7 +22,7 @@ Form Inputs:
 - Switch: { label: string, name: string, checked?: boolean } - Toggle switch
 
 Actions:
-- Button: { label: string, variant?: "primary"|"secondary"|"danger", action?: string } - Clickable button
+- Button: { label: string, variant?: "primary"|"secondary"|"danger", actionText?: string } - Clickable button. actionText is shown in toast on click (defaults to label)
 - Link: { label: string, href: string } - Anchor link
 
 Typography:
@@ -37,6 +37,10 @@ Data Display:
 - Progress: { value: number, max?: number, label?: string } - Progress bar (value 0-100)
 - Rating: { value: number, max?: number, label?: string } - Star rating display
 
+Charts:
+- BarGraph: { title?: string, data: Array<{label: string, value: number}> } - Vertical bar chart
+- LineGraph: { title?: string, data: Array<{label: string, value: number}> } - Line chart with points
+
 OUTPUT FORMAT (JSONL):
 {"op":"set","path":"/root","value":"element-key"}
 {"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
@@ -51,11 +55,26 @@ RULES:
 5. Each element needs: key, type, props
 6. Use className for custom Tailwind styling when needed
 
-EXAMPLE:
-{"op":"set","path":"/root","value":"card"}
-{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact","maxWidth":"sm","centered":true},"children":["name","submit"]}}
-{"op":"add","path":"/elements/name","value":{"key":"name","type":"Input","props":{"label":"Name","name":"name"}}}
-{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Submit","variant":"primary"}}}
+FORBIDDEN CLASSES (NEVER USE):
+- min-h-screen, h-screen, min-h-full, h-full, min-h-dvh, h-dvh - viewport heights break the small render container
+- bg-gray-50, bg-slate-50 or any page background colors - container already has background
+
+MOBILE-FIRST RESPONSIVE:
+- ALWAYS design mobile-first. Single column on mobile, expand on larger screens.
+- Grid: Use columns:1 prop, add className:["sm:grid-cols-2"] or ["md:grid-cols-3"] for larger screens
+- DO NOT put page headers/titles inside Card - use Stack with Heading directly
+- Horizontal stacks that may overflow should use className:["flex-wrap"]
+- For forms (login, signup, contact): Card should be the root element, NOT wrapped in a centering Stack
+
+EXAMPLE (Blog with responsive grid):
+{"op":"set","path":"/root","value":"page"}
+{"op":"add","path":"/elements/page","value":{"key":"page","type":"Stack","props":{"direction":"vertical","gap":"lg"},"children":["header","posts"]}}
+{"op":"add","path":"/elements/header","value":{"key":"header","type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["title","desc"]}}
+{"op":"add","path":"/elements/title","value":{"key":"title","type":"Heading","props":{"text":"My Blog","level":1}}}
+{"op":"add","path":"/elements/desc","value":{"key":"desc","type":"Text","props":{"content":"Latest posts","variant":"muted"}}}
+{"op":"add","path":"/elements/posts","value":{"key":"posts","type":"Grid","props":{"columns":1,"gap":"md","className":["sm:grid-cols-2","lg:grid-cols-3"]},"children":["post1"]}}
+{"op":"add","path":"/elements/post1","value":{"key":"post1","type":"Card","props":{"title":"Post Title"},"children":["excerpt"]}}
+{"op":"add","path":"/elements/excerpt","value":{"key":"excerpt","type":"Text","props":{"content":"Post content...","variant":"body"}}}
 
 Generate JSONL:`;
 

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

@@ -1,9 +1,11 @@
 "use client";
 
-import React, { useEffect, useState, useCallback, useRef } from "react";
+import React, { useEffect, useState, useCallback } from "react";
 import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
 import type { UITree } from "@json-render/core";
+import { toast } from "sonner";
 import { CodeBlock } from "./code-block";
+import { Toaster } from "./ui/sonner";
 import { demoRegistry, fallbackComponent, useInteractiveState } from "./demo/index";
 
 const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
@@ -65,7 +67,6 @@ export function Demo() {
   const [stageIndex, setStageIndex] = useState(-1);
   const [streamLines, setStreamLines] = useState<string[]>([]);
   const [activeTab, setActiveTab] = useState<Tab>("json");
-  const [actionFired, setActionFired] = useState(false);
   const [simulationTree, setSimulationTree] = useState<UITree | null>(null);
 
   // Use the library's useUIStream hook for real API calls
@@ -84,8 +85,10 @@ export function Demo() {
 
   const currentSimulationStage = stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
 
-  // Determine which tree to display
-  const currentTree = mode === "simulation" ? (currentSimulationStage?.tree || simulationTree) : apiTree;
+  // Determine which tree to display - keep simulation tree until new API response
+  const currentTree = mode === "simulation" 
+    ? (currentSimulationStage?.tree || simulationTree) 
+    : (apiTree || simulationTree);
 
   const stopGeneration = useCallback(() => {
     if (mode === "simulation") {
@@ -165,14 +168,13 @@ export function Demo() {
     await send(userPrompt);
   }, [userPrompt, isStreaming, send]);
 
-  // Expose action handler for registry components
+  // Expose action handler for registry components - shows toast with text
   useEffect(() => {
-    (window as unknown as { __demoAction?: () => void }).__demoAction = () => {
-      setActionFired(true);
-      setTimeout(() => setActionFired(false), 2000);
+    (window as unknown as { __demoAction?: (text: string) => void }).__demoAction = (text: string) => {
+      toast(text);
     };
     return () => {
-      delete (window as unknown as { __demoAction?: () => void }).__demoAction;
+      delete (window as unknown as { __demoAction?: (text: string) => void }).__demoAction;
     };
   }, []);
 
@@ -321,11 +323,6 @@ export function Demo() {
                       fallback={fallbackComponent as Parameters<typeof Renderer>[0]['fallback']}
                     />
                   </JSONUIProvider>
-                  {actionFired && (
-                    <div className="mt-3 text-xs font-mono text-muted-foreground text-center animate-in fade-in slide-in-from-bottom-2">
-                      onAction()
-                    </div>
-                  )}
                 </div>
               </div>
             ) : (
@@ -334,6 +331,7 @@ export function Demo() {
               </div>
             )}
           </div>
+          <Toaster position="bottom-right" />
         </div>
       </div>
     </div>

+ 41 - 0
apps/web/components/demo/bar-graph.tsx

@@ -0,0 +1,41 @@
+"use client";
+
+import type { ComponentRenderProps } from "./types";
+import { baseClass, getCustomClass } from "./utils";
+
+interface DataPoint {
+  label: string;
+  value: number;
+}
+
+export function BarGraph({ element }: ComponentRenderProps) {
+  const { props } = element;
+  const customClass = getCustomClass(props);
+  const data = (props.data as DataPoint[]) || [];
+  const title = props.title as string | undefined;
+  const maxValue = Math.max(...data.map((d) => d.value), 1);
+
+  return (
+    <div className={`${baseClass} ${customClass}`}>
+      {title ? (
+        <div className="text-xs font-medium mb-2 text-left">{title}</div>
+      ) : null}
+      <div className="flex items-end gap-1 h-24">
+        {data.map((d, i) => (
+          <div key={i} className="flex-1 flex flex-col items-center gap-1">
+            <div className="text-[8px] text-muted-foreground">
+              {d.value}
+            </div>
+            <div
+              className="w-full bg-foreground/80 rounded-t transition-all"
+              style={{ height: `${(d.value / maxValue) * 100}%`, minHeight: 2 }}
+            />
+            <div className="text-[8px] text-muted-foreground truncate w-full text-center">
+              {d.label}
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 7 - 2
apps/web/components/demo/button.tsx

@@ -7,6 +7,8 @@ export function Button({ element }: ComponentRenderProps) {
   const { props } = element;
   const customClass = getCustomClass(props);
   const variant = props.variant as string;
+  const label = props.label as string;
+  const actionText = (props.actionText as string) || label;
   const btnClass =
     variant === "danger"
       ? "bg-red-500 text-white"
@@ -16,12 +18,15 @@ export function Button({ element }: ComponentRenderProps) {
 
   return (
     <button
+      type="button"
       onClick={() =>
-        (window as unknown as { __demoAction?: () => void }).__demoAction?.()
+        (
+          window as unknown as { __demoAction?: (text: string) => void }
+        ).__demoAction?.(actionText)
       }
       className={`self-start px-3 py-1.5 rounded text-xs font-medium hover:opacity-90 transition-opacity ${btnClass} ${baseClass} ${customClass}`}
     >
-      {props.label as string}
+      {label}
     </button>
   );
 }

+ 6 - 0
apps/web/components/demo/index.ts

@@ -6,6 +6,7 @@ export { useInteractiveState } from "./utils";
 export { Alert } from "./alert";
 export { Avatar } from "./avatar";
 export { Badge } from "./badge";
+export { BarGraph } from "./bar-graph";
 export { Button } from "./button";
 export { Card } from "./card";
 export { Checkbox } from "./checkbox";
@@ -16,6 +17,7 @@ export { Grid } from "./grid";
 export { Heading } from "./heading";
 export { Image } from "./image";
 export { Input } from "./input";
+export { LineGraph } from "./line-graph";
 export { Link } from "./link";
 export { Progress } from "./progress";
 export { Radio } from "./radio";
@@ -30,6 +32,7 @@ import type { ComponentRegistry } from "./types";
 import { Alert } from "./alert";
 import { Avatar } from "./avatar";
 import { Badge } from "./badge";
+import { BarGraph } from "./bar-graph";
 import { Button } from "./button";
 import { Card } from "./card";
 import { Checkbox } from "./checkbox";
@@ -40,6 +43,7 @@ import { Grid } from "./grid";
 import { Heading } from "./heading";
 import { Image } from "./image";
 import { Input } from "./input";
+import { LineGraph } from "./line-graph";
 import { Link } from "./link";
 import { Progress } from "./progress";
 import { Radio } from "./radio";
@@ -54,6 +58,7 @@ export const demoRegistry: ComponentRegistry = {
   Alert,
   Avatar,
   Badge,
+  BarGraph,
   Button,
   Card,
   Checkbox,
@@ -63,6 +68,7 @@ export const demoRegistry: ComponentRegistry = {
   Heading,
   Image,
   Input,
+  LineGraph,
   Link,
   Progress,
   Radio,

+ 94 - 0
apps/web/components/demo/line-graph.tsx

@@ -0,0 +1,94 @@
+"use client";
+
+import type { ComponentRenderProps } from "./types";
+import { baseClass, getCustomClass } from "./utils";
+
+interface DataPoint {
+  label: string;
+  value: number;
+}
+
+export function LineGraph({ element }: ComponentRenderProps) {
+  const { props } = element;
+  const customClass = getCustomClass(props);
+  const data = (props.data as DataPoint[]) || [];
+  const title = props.title as string | undefined;
+  const maxValue = Math.max(...data.map((d) => d.value), 1);
+  const minValue = Math.min(...data.map((d) => d.value), 0);
+  const range = maxValue - minValue || 1;
+
+  // Calculate points for the SVG path
+  const points = data.map((d, i) => {
+    const x = data.length > 1 ? (i / (data.length - 1)) * 100 : 50;
+    const y = 100 - ((d.value - minValue) / range) * 100;
+    return { x, y, ...d };
+  });
+
+  const pathD =
+    points.length > 0
+      ? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
+      : "";
+
+  return (
+    <div className={`${baseClass} ${customClass}`}>
+      {title ? (
+        <div className="text-xs font-medium mb-2 text-left">{title}</div>
+      ) : null}
+      <div className="relative h-24">
+        <svg
+          viewBox="0 0 100 100"
+          preserveAspectRatio="none"
+          className="w-full h-full"
+        >
+          {/* Grid lines */}
+          <line
+            x1="0"
+            y1="50"
+            x2="100"
+            y2="50"
+            stroke="currentColor"
+            strokeOpacity="0.1"
+            vectorEffect="non-scaling-stroke"
+          />
+          {/* Line */}
+          {pathD && (
+            <path
+              d={pathD}
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              vectorEffect="non-scaling-stroke"
+              className="text-foreground/80"
+            />
+          )}
+          {/* Points */}
+          {points.map((p, i) => (
+            <circle
+              key={i}
+              cx={p.x}
+              cy={p.y}
+              r="3"
+              className="fill-foreground"
+              vectorEffect="non-scaling-stroke"
+            />
+          ))}
+        </svg>
+      </div>
+      {data.length > 0 && (
+        <div className="flex justify-between mt-1">
+          {data.map((d, i) => (
+            <div
+              key={i}
+              className="text-[8px] text-muted-foreground text-center"
+              style={{ width: `${100 / data.length}%` }}
+            >
+              {d.label}
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 23 - 0
apps/web/components/ui/sonner.tsx

@@ -0,0 +1,23 @@
+"use client"
+
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+  return (
+    <Sonner
+      theme="light"
+      className="toaster group"
+      style={
+        {
+          "--normal-bg": "var(--popover)",
+          "--normal-text": "var(--popover-foreground)",
+          "--normal-border": "var(--border)",
+          "--border-radius": "var(--radius)",
+        } as React.CSSProperties
+      }
+      {...props}
+    />
+  )
+}
+
+export { Toaster }

+ 2 - 0
apps/web/package.json

@@ -21,9 +21,11 @@
     "clsx": "^2.1.1",
     "lucide-react": "^0.562.0",
     "next": "16.1.1",
+    "next-themes": "^0.4.6",
     "react": "19.2.3",
     "react-dom": "19.2.3",
     "shiki": "^3.21.0",
+    "sonner": "^2.0.7",
     "tailwind-merge": "^3.4.0",
     "zod": "^3.24.0"
   },

+ 28 - 0
pnpm-lock.yaml

@@ -50,6 +50,9 @@ importers:
       next:
         specifier: 16.1.1
         version: 16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+      next-themes:
+        specifier: ^0.4.6
+        version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       react:
         specifier: 19.2.3
         version: 19.2.3
@@ -59,6 +62,9 @@ importers:
       shiki:
         specifier: ^3.21.0
         version: 3.21.0
+      sonner:
+        specifier: ^2.0.7
+        version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
       tailwind-merge:
         specifier: ^3.4.0
         version: 3.4.0
@@ -2005,6 +2011,12 @@ packages:
   natural-compare@1.4.0:
     resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
 
+  next-themes@0.4.6:
+    resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
+    peerDependencies:
+      react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
   next@16.1.1:
     resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==}
     engines: {node: '>=20.9.0'}
@@ -2288,6 +2300,12 @@ packages:
     resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
     engines: {node: '>= 0.4'}
 
+  sonner@2.0.7:
+    resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
+    peerDependencies:
+      react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+      react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
   source-map-js@1.2.1:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
@@ -4269,6 +4287,11 @@ snapshots:
 
   natural-compare@1.4.0: {}
 
+  next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+    dependencies:
+      react: 19.2.3
+      react-dom: 19.2.3(react@19.2.3)
+
   next@16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
     dependencies:
       '@next/env': 16.1.1
@@ -4636,6 +4659,11 @@ snapshots:
       side-channel-map: 1.0.1
       side-channel-weakmap: 1.0.2
 
+  sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+    dependencies:
+      react: 19.2.3
+      react-dom: 19.2.3(react@19.2.3)
+
   source-map-js@1.2.1: {}
 
   source-map@0.7.6: {}