Преглед на файлове

improve output (#268)

* improve output

* fixes
Chris Tate преди 3 месеца
родител
ревизия
ee596e4901

+ 4 - 1
apps/web/app/api/generate/route.ts

@@ -17,10 +17,13 @@ const PLAYGROUND_RULES = [
   "NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
   "NEVER use page background colors (bg-gray-50) - the container has its own background.",
   "For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
-  "For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
+  "For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts. Keep the total UI compact — avoid sprawling multi-section pages. Prefer a single focused Card over a full page layout.",
   "Wrap each repeated item in a Card for visual separation and structure.",
   "Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
   'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
+  "NEVER use emoji characters. Use the Icon component with Lucide icon names instead. For example, use Icon with name:'MapPin' instead of a pin emoji, Icon with name:'Mail' instead of an envelope emoji, etc.",
+  "For icon+label patterns, use a horizontal Stack with gap:'sm' and align:'center' containing an Icon and a Text.",
+  "For any tabular or list data with consistent columns (items, orders, stats), ALWAYS use the Table component. Never simulate tables with Stacks — the columns won't align.",
 ];
 
 const MAX_PROMPT_LENGTH = 500;

+ 66 - 67
apps/web/components/demo.tsx

@@ -18,65 +18,62 @@ import { PlaygroundRenderer } from "@/lib/render/renderer";
 import { playgroundCatalog } from "@/lib/render/catalog";
 import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
 
-const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
+const SIMULATION_PROMPT = "Show a team performance dashboard";
 
 interface SimulationStage {
   tree: Spec;
   stream: string;
 }
 
-// Shared state & element definitions for the progressive simulation stages.
-const FORM_STATE = { form: { name: "", email: "", message: "" } };
+const DASH_STATE = {
+  chartData: [
+    { label: "Mon", value: 12 },
+    { label: "Tue", value: 28 },
+    { label: "Wed", value: 19 },
+    { label: "Thu", value: 34 },
+    { label: "Fri", value: 45 },
+    { label: "Sat", value: 38 },
+    { label: "Sun", value: 52 },
+  ],
+};
 
-const NAME_INPUT = {
-  type: "Input",
+const METRIC_REVENUE = {
+  type: "Metric",
   props: {
-    label: "Name",
-    name: "name",
-    statePath: "/form/name",
-    checks: [{ type: "required", message: "Name is required" }],
+    label: "Weekly Revenue",
+    value: "12,400",
+    prefix: "$",
+    change: "+18%",
+    changeType: "positive",
   },
 } as const;
 
-const EMAIL_INPUT = {
-  type: "Input",
-  props: {
-    label: "Email",
-    name: "email",
-    type: "email",
-    statePath: "/form/email",
-    checks: [
-      { type: "required", message: "Email is required" },
-      { type: "email", message: "Please enter a valid email" },
-    ],
-  },
+const CHART = {
+  type: "LineGraph",
+  props: { data: { $state: "/chartData" } },
 } as const;
 
-const MESSAGE_INPUT = {
-  type: "Textarea",
-  props: {
-    label: "Message",
-    name: "message",
-    statePath: "/form/message",
-    checks: [{ type: "required", message: "Message is required" }],
-  },
+const SEP = { type: "Separator", props: {} } as const;
+
+const PROGRESS_DEALS = {
+  type: "Progress",
+  props: { value: 72, label: "Deals Closed -- 72%" },
 } as const;
 
-const SUBMIT_BUTTON = {
-  type: "Button",
-  props: { label: "Send Message", variant: "primary" },
-  on: { press: { action: "formSubmit" } },
+const PROGRESS_RETENTION = {
+  type: "Progress",
+  props: { value: 91, label: "Retention -- 91%" },
 } as const;
 
 const SIMULATION_STAGES: SimulationStage[] = [
   {
     tree: {
       root: "card",
-      state: FORM_STATE,
+      state: DASH_STATE,
       elements: {
         card: {
           type: "Card",
-          props: { title: "Contact Us", maxWidth: "md" },
+          props: { title: "Team Performance", maxWidth: "sm", centered: true },
           children: [],
         },
       },
@@ -86,72 +83,74 @@ const SIMULATION_STAGES: SimulationStage[] = [
   {
     tree: {
       root: "card",
-      state: FORM_STATE,
+      state: DASH_STATE,
       elements: {
         card: {
           type: "Card",
-          props: { title: "Contact Us", maxWidth: "md" },
-          children: ["name"],
+          props: { title: "Team Performance", maxWidth: "sm", centered: true },
+          children: ["m1"],
         },
-        name: NAME_INPUT,
+        m1: METRIC_REVENUE,
       },
     },
     stream:
-      '{"op":"add","path":"/elements/name","value":{"type":"Input","props":{"label":"Name","name":"name","statePath":"/form/name","checks":[{"type":"required","message":"Name is required"}]}}}',
+      '{"op":"add","path":"/elements/m1","value":{"type":"Metric","props":{"label":"Weekly Revenue","value":"12,400","prefix":"$","change":"+18%","changeType":"positive"}}}',
   },
   {
     tree: {
       root: "card",
-      state: FORM_STATE,
+      state: DASH_STATE,
       elements: {
         card: {
           type: "Card",
-          props: { title: "Contact Us", maxWidth: "md" },
-          children: ["name", "email"],
+          props: { title: "Team Performance", maxWidth: "sm", centered: true },
+          children: ["m1", "chart"],
         },
-        name: NAME_INPUT,
-        email: EMAIL_INPUT,
+        m1: METRIC_REVENUE,
+        chart: CHART,
       },
     },
     stream:
-      '{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email","type":"email","statePath":"/form/email","checks":[{"type":"required","message":"Email is required"},{"type":"email","message":"Please enter a valid email"}]}}}',
+      '{"op":"add","path":"/elements/chart","value":{"type":"LineGraph","props":{"data":{"$state":"/chartData"}}}}',
   },
   {
     tree: {
       root: "card",
-      state: FORM_STATE,
+      state: DASH_STATE,
       elements: {
         card: {
           type: "Card",
-          props: { title: "Contact Us", maxWidth: "md" },
-          children: ["name", "email", "message"],
+          props: { title: "Team Performance", maxWidth: "sm", centered: true },
+          children: ["m1", "chart", "sep", "p1"],
         },
-        name: NAME_INPUT,
-        email: EMAIL_INPUT,
-        message: MESSAGE_INPUT,
+        m1: METRIC_REVENUE,
+        chart: CHART,
+        sep: SEP,
+        p1: PROGRESS_DEALS,
       },
     },
     stream:
-      '{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message","statePath":"/form/message","checks":[{"type":"required","message":"Message is required"}]}}}',
+      '{"op":"add","path":"/elements/p1","value":{"type":"Progress","props":{"value":72,"label":"Deals Closed -- 72%"}}}',
   },
   {
     tree: {
       root: "card",
-      state: FORM_STATE,
+      state: DASH_STATE,
       elements: {
         card: {
           type: "Card",
-          props: { title: "Contact Us", maxWidth: "md" },
-          children: ["name", "email", "message", "submit"],
+          props: { title: "Team Performance", maxWidth: "sm", centered: true },
+          children: ["m1", "chart", "sep", "p1", "p2"],
         },
-        name: NAME_INPUT,
-        email: EMAIL_INPUT,
-        message: MESSAGE_INPUT,
-        submit: SUBMIT_BUTTON,
+        m1: METRIC_REVENUE,
+        chart: CHART,
+        sep: SEP,
+        p1: PROGRESS_DEALS,
+        p2: PROGRESS_RETENTION,
       },
     },
     stream:
-      '{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"},"on":{"press":{"action":"formSubmit"}}}}',
+      '{"op":"add","path":"/elements/p2","value":{"type":"Progress","props":{"value":91,"label":"Retention -- 91%"}}}',
   },
 ];
 
@@ -211,10 +210,10 @@ function specToNested(spec: Spec): Record<string, unknown> {
 }
 
 const EXAMPLE_PROMPTS = [
-  "Create a login form with email and password",
-  "Build a feedback form with rating stars",
-  "Design a contact card with avatar",
-  "Make a settings panel with toggles",
+  "Recipe card with rating and ingredients",
+  "Order receipt with item list and total",
+  "Team member profile card",
+  "Notification inbox with alerts",
 ];
 
 export function Demo({
@@ -1117,7 +1116,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
             ))}
           </div>
           <div
-            className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
+            className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
           >
             {activeTab !== "catalog" && (
               <div className="absolute top-2 right-2 z-10">
@@ -1357,7 +1356,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
             </div>
           </div>
           <div
-            className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
+            className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
           >
             {renderView === "static" && (
               <div className="absolute top-2 right-2 z-10">

+ 44 - 4
apps/web/lib/render/catalog.ts

@@ -165,7 +165,8 @@ export const playgroundCatalog = defineCatalog(schema, {
           .enum(["body", "caption", "muted", "lead", "code"])
           .nullable(),
       }),
-      description: "Paragraph text",
+      description:
+        'Paragraph text. In repeat scopes, use { "$template": "${field1} ${field2}" } to interpolate item fields.',
       example: { text: "Hello, world!" },
     },
 
@@ -178,6 +179,18 @@ export const playgroundCatalog = defineCatalog(schema, {
       description: "Placeholder image (displays alt text in a styled box)",
     },
 
+    Icon: {
+      props: z.object({
+        name: z.string(),
+        size: z.enum(["sm", "md", "lg"]).nullable(),
+        color: z
+          .enum(["default", "muted", "primary", "success", "warning", "danger"])
+          .nullable(),
+      }),
+      description:
+        "Lucide icon by name. PascalCase: MapPin, Mail, Globe, Calendar, Star, Heart, Check, X, ArrowRight, Phone, Building, Clock, Shield, Zap, Users, Eye, Download, Upload, Search, Filter, Settings, Bell, ChevronRight, ExternalLink, Info, AlertTriangle, CheckCircle, XCircle. Use in horizontal Stacks with Text for icon+label patterns. Never use emoji — always use Icon.",
+    },
+
     Avatar: {
       props: z.object({
         src: z.string().nullable(),
@@ -259,8 +272,32 @@ export const playgroundCatalog = defineCatalog(schema, {
         value: z.number(),
         max: z.number().nullable(),
         label: z.string().nullable(),
+        interactive: z.boolean().nullable(),
+      }),
+      events: ["change"],
+      description:
+        "Interactive star rating. Use { $bindState } on value for binding. Set interactive: false for read-only display.",
+      example: { value: 4, max: 5, label: "Rating" },
+    },
+
+    Metric: {
+      props: z.object({
+        label: z.string(),
+        value: z.string(),
+        change: z.string().nullable(),
+        changeType: z.enum(["positive", "negative", "neutral"]).nullable(),
+        prefix: z.string().nullable(),
+        suffix: z.string().nullable(),
       }),
-      description: "Star rating display",
+      description:
+        "Key metric / stat display. Shows a large value with label and optional change indicator. Use for dashboard KPIs.",
+      example: {
+        label: "Total Revenue",
+        value: "125,000",
+        prefix: "$",
+        change: "+12.5%",
+        changeType: "positive",
+      },
     },
 
     // ── Charts ──────────────────────────────────────────────────────────
@@ -411,11 +448,14 @@ export const playgroundCatalog = defineCatalog(schema, {
     Button: {
       props: z.object({
         label: z.string(),
-        variant: z.enum(["primary", "secondary", "danger"]).nullable(),
+        variant: z
+          .enum(["primary", "secondary", "outline", "danger"])
+          .nullable(),
         disabled: z.boolean().nullable(),
       }),
       events: ["press"],
-      description: "Clickable button. Bind on.press for handler.",
+      description:
+        "Clickable button. primary = solid fill, outline = bordered/transparent, secondary = muted fill. Bind on.press for handler.",
       example: { label: "Submit", variant: "primary" },
     },
 

+ 356 - 125
apps/web/lib/render/registry.tsx

@@ -105,6 +105,7 @@ import {
   TooltipProvider,
   TooltipTrigger,
 } from "@/components/ui/tooltip";
+import { icons as lucideIcons } from "lucide-react";
 
 // =============================================================================
 // Registry — components + actions, types inferred from catalog
@@ -127,23 +128,25 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
 
       return (
         <div
-          className={`border border-border rounded-lg p-4 bg-card text-card-foreground overflow-hidden ${maxWidthClass} ${centeredClass}`}
+          className={`border border-border rounded-xl p-5 bg-card text-card-foreground shadow-sm overflow-hidden h-full flex flex-col ${maxWidthClass} ${centeredClass}`}
         >
           {(props.title || props.description) && (
             <div className="mb-4">
               {props.title && (
-                <h3 className="font-semibold text-lg text-left">
+                <h3 className="font-semibold text-lg tracking-tight">
                   {props.title}
                 </h3>
               )}
               {props.description && (
-                <p className="text-sm text-muted-foreground mt-1 text-left">
+                <p className="text-sm text-muted-foreground mt-1">
                   {props.description}
                 </p>
               )}
             </div>
           )}
-          <div className="space-y-3">{children}</div>
+          <div className="flex-1 flex flex-col gap-4 [&>:last-child]:mt-auto">
+            {children}
+          </div>
         </div>
       );
     },
@@ -160,14 +163,31 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
               : props.gap === "none"
                 ? "gap-0"
                 : "gap-3";
-      const alignClass =
-        props.align === "center"
-          ? "items-center"
-          : props.align === "end"
-            ? "items-end"
-            : props.align === "stretch"
-              ? "items-stretch"
-              : "items-start";
+
+      let alignClass: string;
+      if (isHorizontal) {
+        alignClass =
+          props.align === "center"
+            ? "items-center"
+            : props.align === "end"
+              ? "items-end"
+              : props.align === "stretch"
+                ? "items-stretch"
+                : "items-start";
+      } else {
+        // Vertical: items-center/end lets inline elements (Avatar, Badge, Button)
+        // center/align naturally. Block containers (Grid, Accordion, Table) add
+        // their own w-full to stretch regardless.
+        alignClass =
+          props.align === "center"
+            ? "items-center"
+            : props.align === "end"
+              ? "items-end"
+              : props.align === "start"
+                ? "items-start"
+                : "items-stretch";
+      }
+
       const justifyClass =
         props.justify === "center"
           ? "justify-center"
@@ -181,7 +201,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
 
       return (
         <div
-          className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
+          className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col w-full"} ${gapClass} ${alignClass} ${justifyClass}`}
         >
           {children}
         </div>
@@ -189,7 +209,12 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
     },
 
     Grid: ({ props, children }) => {
-      const n = props.columns ?? 1;
+      const childCount = Array.isArray(children)
+        ? children.length
+        : children
+          ? 1
+          : 0;
+      const n = Math.min(props.columns ?? 1, childCount || 1);
       const cols =
         n >= 6
           ? "grid-cols-6"
@@ -205,13 +230,15 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const gridGap =
         props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
 
-      return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
+      return <div className={`grid w-full ${cols} ${gridGap}`}>{children}</div>;
     },
 
     Separator: ({ props }) => (
       <Separator
         orientation={props.orientation ?? "horizontal"}
-        className={props.orientation === "vertical" ? "h-full mx-2" : "my-3"}
+        className={
+          props.orientation === "vertical" ? "h-full mx-3" : "my-4 opacity-50"
+        }
       />
     ),
 
@@ -383,7 +410,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       });
 
       return (
-        <div className="rounded-md border border-border overflow-hidden">
+        <div className="w-full rounded-md border border-border overflow-hidden">
           <TablePrimitive>
             {props.caption && <TableCaption>{props.caption}</TableCaption>}
             <TableHeader>
@@ -411,20 +438,17 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const level = props.level ?? "h2";
       const headingClass =
         level === "h1"
-          ? "text-2xl font-bold"
+          ? "text-2xl font-bold tracking-tight"
           : level === "h3"
-            ? "text-base font-semibold"
+            ? "text-base font-semibold tracking-tight"
             : level === "h4"
-              ? "text-sm font-semibold"
-              : "text-lg font-semibold";
-
-      if (level === "h1")
-        return <h1 className={`${headingClass} text-left`}>{props.text}</h1>;
-      if (level === "h3")
-        return <h3 className={`${headingClass} text-left`}>{props.text}</h3>;
-      if (level === "h4")
-        return <h4 className={`${headingClass} text-left`}>{props.text}</h4>;
-      return <h2 className={`${headingClass} text-left`}>{props.text}</h2>;
+              ? "text-sm font-medium uppercase tracking-wider text-muted-foreground"
+              : "text-xl font-semibold tracking-tight";
+
+      if (level === "h1") return <h1 className={headingClass}>{props.text}</h1>;
+      if (level === "h3") return <h3 className={headingClass}>{props.text}</h3>;
+      if (level === "h4") return <h4 className={headingClass}>{props.text}</h4>;
+      return <h2 className={headingClass}>{props.text}</h2>;
     },
 
     Text: ({ props }) => {
@@ -440,20 +464,59 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
                 : "text-sm";
 
       if (props.variant === "code") {
-        return <code className={`${textClass} text-left`}>{props.text}</code>;
+        return <code className={textClass}>{props.text}</code>;
       }
-      return <p className={`${textClass} text-left`}>{props.text}</p>;
+      return <p className={textClass}>{props.text}</p>;
     },
 
     Image: ({ props }) => (
       <div
-        className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground aspect-video"
-        style={{ width: props.width ?? 80, height: props.height ?? 60 }}
+        className="w-full bg-muted/50 border border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground/60 px-4"
+        style={{
+          maxWidth: props.width ?? undefined,
+          height: props.height ?? 120,
+          minHeight: 80,
+        }}
       >
-        {props.alt || "img"}
+        <svg
+          width="32"
+          height="32"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="currentColor"
+          strokeWidth="1.5"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          className="opacity-50"
+        >
+          <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
+          <circle cx="9" cy="9" r="2" />
+          <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
+        </svg>
+        {props.alt && <span className="text-xs">{props.alt}</span>}
       </div>
     ),
 
+    Icon: ({ props }) => {
+      const IconComponent = lucideIcons[props.name as keyof typeof lucideIcons];
+      if (!IconComponent) return null;
+      const sizeMap = { sm: 16, md: 20, lg: 24 } as const;
+      const px = sizeMap[props.size ?? "md"] ?? 20;
+      const colorClass =
+        props.color === "muted"
+          ? "text-muted-foreground"
+          : props.color === "primary"
+            ? "text-primary"
+            : props.color === "success"
+              ? "text-green-600 dark:text-green-400"
+              : props.color === "warning"
+                ? "text-yellow-600 dark:text-yellow-400"
+                : props.color === "danger"
+                  ? "text-red-600 dark:text-red-400"
+                  : "";
+      return <IconComponent size={px} className={`shrink-0 ${colorClass}`} />;
+    },
+
     Avatar: ({ props }) => {
       const name = props.name || "?";
       const initials = name
@@ -462,16 +525,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
         .join("")
         .slice(0, 2)
         .toUpperCase();
-      const avatarSize =
+      const sizeStyles =
         props.size === "lg"
-          ? "w-12 h-12 text-base"
+          ? { outer: "w-[72px] h-[72px]", text: "text-xl", ring: "ring-[3px]" }
           : props.size === "sm"
-            ? "w-8 h-8 text-xs"
-            : "w-10 h-10 text-sm";
+            ? { outer: "w-8 h-8", text: "text-xs", ring: "ring-2" }
+            : { outer: "w-10 h-10", text: "text-sm", ring: "ring-2" };
 
       return (
         <div
-          className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium`}
+          className={`${sizeStyles.outer} ${sizeStyles.text} rounded-full bg-gradient-to-br from-muted-foreground/20 to-muted flex items-center justify-center font-semibold tracking-wide ${sizeStyles.ring} ring-background shadow-sm`}
         >
           {initials}
         </div>
@@ -491,9 +554,20 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
           : props.variant === "warning"
             ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"
             : "";
+      const dotColor =
+        props.variant === "success"
+          ? "bg-green-500"
+          : props.variant === "warning"
+            ? "bg-yellow-500"
+            : props.variant === "danger"
+              ? "bg-red-500"
+              : "";
 
       return (
-        <Badge variant={variant} className={customClass}>
+        <Badge variant={variant} className={`${customClass} gap-1.5`}>
+          {dotColor && (
+            <span className={`w-1.5 h-1.5 rounded-full ${dotColor} shrink-0`} />
+          )}
           {props.text}
         </Badge>
       );
@@ -510,8 +584,47 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
               ? "border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100"
               : "";
 
+      const iconProps = {
+        width: 16,
+        height: 16,
+        viewBox: "0 0 24 24",
+        fill: "none",
+        stroke: "currentColor",
+        strokeWidth: 2,
+        strokeLinecap: "round" as const,
+        strokeLinejoin: "round" as const,
+        className: "shrink-0",
+      };
+
+      const icon =
+        props.type === "success" ? (
+          <svg {...iconProps}>
+            <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+            <polyline points="22 4 12 14.01 9 11.01" />
+          </svg>
+        ) : props.type === "warning" ? (
+          <svg {...iconProps}>
+            <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
+            <line x1="12" y1="9" x2="12" y2="13" />
+            <line x1="12" y1="17" x2="12.01" y2="17" />
+          </svg>
+        ) : props.type === "error" ? (
+          <svg {...iconProps}>
+            <circle cx="12" cy="12" r="10" />
+            <line x1="15" y1="9" x2="9" y2="15" />
+            <line x1="9" y1="9" x2="15" y2="15" />
+          </svg>
+        ) : (
+          <svg {...iconProps}>
+            <circle cx="12" cy="12" r="10" />
+            <line x1="12" y1="16" x2="12" y2="12" />
+            <line x1="12" y1="8" x2="12.01" y2="8" />
+          </svg>
+        );
+
       return (
         <Alert variant={variant} className={customClass}>
+          {icon}
           <AlertTitle>{props.title}</AlertTitle>
           {props.message && (
             <AlertDescription>{props.message}</AlertDescription>
@@ -523,7 +636,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
     Progress: ({ props }) => {
       const value = Math.min(100, Math.max(0, props.value || 0));
       return (
-        <div className="space-y-2">
+        <div className="w-full space-y-2">
           {props.label && (
             <Label className="text-sm text-muted-foreground">
               {props.label}
@@ -607,9 +720,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       </PopoverPrimitive>
     ),
 
-    Rating: ({ props }) => {
-      const ratingValue = props.value || 0;
+    Rating: ({ props, bindings, emit }) => {
+      const [boundValue, setBoundValue] = useBoundProp<number>(
+        props.value as number | undefined,
+        bindings?.value,
+      );
+      const [localValue, setLocalValue] = useState(props.value || 0);
+      const isBound = !!bindings?.value;
+      const ratingValue = isBound ? (boundValue ?? 0) : localValue;
+      const setValue = isBound ? setBoundValue : setLocalValue;
       const maxRating = props.max ?? 5;
+      const interactive = props.interactive !== false;
+      const [hoverIndex, setHoverIndex] = useState(-1);
+
       return (
         <div className="space-y-2">
           {props.label && (
@@ -617,15 +740,78 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
               {props.label}
             </Label>
           )}
-          <div className="flex gap-1">
-            {Array.from({ length: maxRating }).map((_, i) => (
-              <span
-                key={i}
-                className={`text-lg ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
-              >
-                *
+          <div
+            className="flex gap-0.5"
+            onMouseLeave={() => interactive && setHoverIndex(-1)}
+          >
+            {Array.from({ length: maxRating }).map((_, i) => {
+              const filled =
+                hoverIndex >= 0 ? i <= hoverIndex : i < ratingValue;
+              return (
+                <button
+                  key={i}
+                  type="button"
+                  className={`p-0.5 transition-colors ${interactive ? "cursor-pointer hover:scale-110" : "cursor-default"}`}
+                  onMouseEnter={() => interactive && setHoverIndex(i)}
+                  onClick={() => {
+                    if (!interactive) return;
+                    const newVal = i + 1 === ratingValue ? 0 : i + 1;
+                    setValue(newVal);
+                    emit("change");
+                  }}
+                >
+                  <svg
+                    width="20"
+                    height="20"
+                    viewBox="0 0 24 24"
+                    fill={filled ? "currentColor" : "none"}
+                    stroke="currentColor"
+                    strokeWidth="1.5"
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    className={
+                      filled ? "text-yellow-400" : "text-muted-foreground/40"
+                    }
+                  >
+                    <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
+                  </svg>
+                </button>
+              );
+            })}
+          </div>
+        </div>
+      );
+    },
+
+    Metric: ({ props }) => {
+      const changeColor =
+        props.changeType === "positive"
+          ? "text-green-600 dark:text-green-400"
+          : props.changeType === "negative"
+            ? "text-red-600 dark:text-red-400"
+            : "text-muted-foreground";
+      const changeIcon =
+        props.changeType === "positive"
+          ? "\u2191"
+          : props.changeType === "negative"
+            ? "\u2193"
+            : "";
+
+      return (
+        <div className="space-y-1">
+          <div className="text-sm text-muted-foreground">{props.label}</div>
+          <div className="flex items-baseline gap-1.5">
+            <span className="text-2xl font-semibold tracking-tight tabular-nums">
+              {props.prefix}
+              {props.value}
+              {props.suffix}
+            </span>
+            {props.change && (
+              <span className={`text-sm font-medium ${changeColor}`}>
+                {changeIcon}
+                {props.change}
               </span>
-            ))}
+            )}
           </div>
         </div>
       );
@@ -636,26 +822,39 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
     BarGraph: ({ props }) => {
       const data = props.data || [];
       const maxValue = Math.max(...data.map((d) => d.value), 1);
+      const barColors = [
+        "bg-primary",
+        "bg-primary/80",
+        "bg-primary/60",
+        "bg-primary/70",
+        "bg-primary/90",
+        "bg-primary/50",
+      ];
 
       return (
-        <div className="space-y-2">
+        <div className="space-y-3">
           {props.title && (
-            <div className="text-sm font-medium text-left">{props.title}</div>
+            <div className="text-sm font-medium">{props.title}</div>
           )}
-          <div className="flex gap-2">
+          <div className="flex items-end gap-2" style={{ height: 160 }}>
             {data.map((d, i) => (
-              <div key={i} className="flex-1 flex flex-col items-center gap-1">
-                <div className="text-xs text-muted-foreground">{d.value}</div>
-                <div className="w-full h-24 flex items-end">
+              <div
+                key={i}
+                className="flex-1 flex flex-col items-center gap-1.5 h-full justify-end group"
+              >
+                <div className="text-[11px] font-medium text-muted-foreground tabular-nums">
+                  {d.value}
+                </div>
+                <div className="w-full flex-1 flex items-end">
                   <div
-                    className="w-full bg-primary rounded-t transition-all"
+                    className={`w-full ${barColors[i % barColors.length]} rounded-t-md transition-all group-hover:opacity-80`}
                     style={{
                       height: `${(d.value / maxValue) * 100}%`,
-                      minHeight: 2,
+                      minHeight: 4,
                     }}
                   />
                 </div>
-                <div className="text-xs text-muted-foreground truncate w-full text-center">
+                <div className="text-[11px] text-muted-foreground truncate w-full text-center">
                   {d.label}
                 </div>
               </div>
@@ -672,8 +871,8 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const range = maxValue - minValue || 1;
 
       const width = 300;
-      const height = 100;
-      const padding = { top: 10, right: 10, bottom: 10, left: 10 };
+      const height = 140;
+      const padding = { top: 12, right: 12, bottom: 12, left: 12 };
       const chartWidth = width - padding.left - padding.right;
       const chartHeight = height - padding.top - padding.bottom;
 
@@ -690,77 +889,107 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
         return { x, y, ...d };
       });
 
-      const pathD =
-        points.length > 0
-          ? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
-          : "";
+      // Build smooth cubic bezier curve through points
+      let smoothPath = "";
+      let areaPath = "";
+      if (points.length > 1) {
+        const first = points[0]!;
+        const last = points[points.length - 1]!;
+        smoothPath = `M ${first.x} ${first.y}`;
+        for (let i = 0; i < points.length - 1; i++) {
+          const curr = points[i]!;
+          const next = points[i + 1]!;
+          const cpx = (curr.x + next.x) / 2;
+          smoothPath += ` C ${cpx} ${curr.y}, ${cpx} ${next.y}, ${next.x} ${next.y}`;
+        }
+        const bottomY = height - padding.bottom;
+        areaPath = `${smoothPath} L ${last.x} ${bottomY} L ${first.x} ${bottomY} Z`;
+      } else if (points.length === 1) {
+        const only = points[0]!;
+        smoothPath = `M ${only.x} ${only.y}`;
+      }
+
+      const gradientId = `line-gradient-${Math.random().toString(36).slice(2, 8)}`;
 
       return (
-        <div className="space-y-2">
+        <div className="space-y-3">
           {props.title && (
-            <div className="text-sm font-medium text-left">{props.title}</div>
+            <div className="text-sm font-medium">{props.title}</div>
           )}
-          <div className="relative h-28">
-            <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
-              <line
-                x1={padding.left}
-                y1={padding.top + chartHeight / 2}
-                x2={width - padding.right}
-                y2={padding.top + chartHeight / 2}
-                stroke="currentColor"
-                strokeOpacity="0.1"
-                strokeWidth="1"
-              />
-              <line
-                x1={padding.left}
-                y1={padding.top}
-                x2={width - padding.right}
-                y2={padding.top}
-                stroke="currentColor"
-                strokeOpacity="0.1"
-                strokeWidth="1"
-              />
-              <line
-                x1={padding.left}
-                y1={height - padding.bottom}
-                x2={width - padding.right}
-                y2={height - padding.bottom}
-                stroke="currentColor"
-                strokeOpacity="0.1"
-                strokeWidth="1"
-              />
-              {pathD && (
+          <div className="relative" style={{ height: 160 }}>
+            <svg
+              viewBox={`0 0 ${width} ${height}`}
+              className="w-full h-full"
+              preserveAspectRatio="none"
+            >
+              <defs>
+                <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
+                  <stop
+                    offset="0%"
+                    stopColor="currentColor"
+                    stopOpacity="0.15"
+                  />
+                  <stop
+                    offset="100%"
+                    stopColor="currentColor"
+                    stopOpacity="0"
+                  />
+                </linearGradient>
+              </defs>
+              {[0, 0.25, 0.5, 0.75, 1].map((frac) => (
+                <line
+                  key={frac}
+                  x1={padding.left}
+                  y1={padding.top + chartHeight * frac}
+                  x2={width - padding.right}
+                  y2={padding.top + chartHeight * frac}
+                  stroke="currentColor"
+                  strokeOpacity="0.07"
+                  vectorEffect="non-scaling-stroke"
+                  strokeWidth="1"
+                />
+              ))}
+              {areaPath && (
                 <path
-                  d={pathD}
+                  d={areaPath}
+                  fill={`url(#${gradientId})`}
+                  className="text-primary"
+                />
+              )}
+              {smoothPath && (
+                <path
+                  d={smoothPath}
                   fill="none"
                   stroke="currentColor"
                   strokeWidth="2"
                   strokeLinecap="round"
                   strokeLinejoin="round"
+                  vectorEffect="non-scaling-stroke"
                   className="text-primary"
                 />
               )}
-              {points.map((p, i) => (
-                <circle
-                  key={i}
-                  cx={p.x}
-                  cy={p.y}
-                  r="4"
-                  className="fill-primary"
-                />
-              ))}
             </svg>
+            {points.map((p, i) => (
+              <div
+                key={i}
+                className="absolute w-[7px] h-[7px] rounded-full bg-primary -translate-x-1/2 -translate-y-1/2"
+                style={{
+                  left: `${(p.x / width) * 100}%`,
+                  top: `${(p.y / height) * 100}%`,
+                }}
+              />
+            ))}
           </div>
-          {data.length > 0 && (
-            <div className="flex justify-between">
-              {data.map((d, i) => (
-                <div
+          {points.length > 0 && (
+            <div className="relative h-4">
+              {points.map((p, i) => (
+                <span
                   key={i}
-                  className="text-xs text-muted-foreground text-center"
-                  style={{ width: `${100 / data.length}%` }}
+                  className="absolute text-[11px] text-muted-foreground -translate-x-1/2"
+                  style={{ left: `${(p.x / width) * 100}%` }}
                 >
-                  {d.label}
-                </div>
+                  {data[i]?.label}
+                </span>
               ))}
             </div>
           )}
@@ -787,7 +1016,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
 
       return (
-        <div className="space-y-2">
+        <div className="w-full space-y-2">
           {props.label && <Label htmlFor={props.name}>{props.label}</Label>}
           <Input
             id={props.name}
@@ -829,7 +1058,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
 
       return (
-        <div className="space-y-2">
+        <div className="w-full space-y-2">
           {props.label && <Label htmlFor={props.name}>{props.label}</Label>}
           <Textarea
             id={props.name}
@@ -872,7 +1101,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       );
 
       return (
-        <div className="space-y-2">
+        <div className="w-full space-y-2">
           <Label>{props.label}</Label>
           <Select
             value={value}
@@ -945,7 +1174,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const setValue = isBound ? setBoundValue : setLocalValue;
 
       return (
-        <div className="space-y-2">
+        <div className="w-full space-y-2">
           {props.label && <Label>{props.label}</Label>}
           <RadioGroup
             value={value}
@@ -987,7 +1216,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const setChecked = isBound ? setBoundChecked : setLocalChecked;
 
       return (
-        <div className="flex items-center justify-between space-x-2">
+        <div className="w-full flex items-center justify-between space-x-2">
           <Label htmlFor={props.name} className="cursor-pointer">
             {props.label}
           </Label>
@@ -1014,7 +1243,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const setValue = isBound ? setBoundValue : setLocalValue;
 
       return (
-        <div className="space-y-2">
+        <div className="w-full space-y-2">
           {props.label && (
             <div className="flex justify-between">
               <Label className="text-sm">{props.label}</Label>
@@ -1041,9 +1270,11 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
       const variant =
         props.variant === "danger"
           ? "destructive"
-          : props.variant === "secondary"
-            ? "secondary"
-            : "default";
+          : props.variant === "outline"
+            ? "outline"
+            : props.variant === "secondary"
+              ? "secondary"
+              : "default";
 
       return (
         <Button

+ 21 - 20
packages/core/src/props.test.ts

@@ -5,7 +5,6 @@ import {
   resolveBindings,
   resolveActionParam,
   _resetWarnedComputedFns,
-  _resetWarnedTemplatePaths,
 } from "./props";
 import type { PropResolutionContext } from "./props";
 
@@ -635,30 +634,32 @@ describe("$template expressions", () => {
     );
   });
 
-  it("warns when path does not start with /", () => {
-    _resetWarnedTemplatePaths();
-    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+  it("resolves bare names against state model when not in repeat", () => {
     const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
     const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
     expect(result).toBe("Hi Bob!");
-    expect(warnSpy).toHaveBeenCalledWith(
-      expect.stringContaining('$template path "name"'),
-    );
-    warnSpy.mockRestore();
-    _resetWarnedTemplatePaths();
   });
 
-  it("deduplicates warnings for the same $template path", () => {
-    _resetWarnedTemplatePaths();
-    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
-    const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
-    resolvePropValue({ $template: "Hi ${name}!" }, ctx);
-    resolvePropValue({ $template: "Hi ${name}!" }, ctx);
-    const calls = warnSpy.mock.calls.filter((c) =>
-      String(c[0]).includes('$template path "name"'),
+  it("resolves bare names against repeat item first", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { name: "Global" },
+      repeatItem: { name: "Alice", action: "signed up" },
+      repeatIndex: 0,
+    };
+    const result = resolvePropValue({ $template: "${name} ${action}" }, ctx);
+    expect(result).toBe("Alice signed up");
+  });
+
+  it("falls back to state model for bare names not in repeat item", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { fallback: "ok" },
+      repeatItem: { name: "Alice" },
+      repeatIndex: 0,
+    };
+    const result = resolvePropValue(
+      { $template: "${name} - ${fallback}" },
+      ctx,
     );
-    expect(calls).toHaveLength(1);
-    warnSpy.mockRestore();
-    _resetWarnedTemplatePaths();
+    expect(result).toBe("Alice - ok");
   });
 });

+ 18 - 24
packages/core/src/props.ts

@@ -24,8 +24,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
  * - `{ $cond, $then, $else }` conditionally picks a value
  * - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
  *    registered function with resolved args and returns the result
- * - `{ $template: string }` interpolates `${/path}` references in the
- *    string with values from the state model
+ * - `{ $template: string }` interpolates `${/path}` and `${field}`
+ *    references. Absolute paths (`${/path}`) resolve against the state
+ *    model. Bare names (`${field}`) resolve against the current repeat
+ *    item first, then fall back to the state model at `/<field>`.
  * - Any other value is a literal (passthrough)
  */
 export type PropExpression<T = unknown> =
@@ -156,15 +158,6 @@ export function _resetWarnedComputedFns(): void {
   warnedComputedFns.clear();
 }
 
-// Same deduplication pattern for $template paths that don't start with "/".
-const WARNED_TEMPLATE_MAX = 100;
-const warnedTemplatePaths = new Set<string>();
-
-/** @internal Test-only: clear the deduplication set for $template warnings. */
-export function _resetWarnedTemplatePaths(): void {
-  warnedTemplatePaths.clear();
-}
-
 // =============================================================================
 // Prop Expression Resolution
 // =============================================================================
@@ -269,24 +262,25 @@ export function resolvePropValue(
     return fn(resolvedArgs);
   }
 
-  // $template: interpolate ${/path} references with state values
+  // $template: interpolate ${/path} and ${field} references.
+  // Absolute paths (starting with "/") resolve against the state model.
+  // Bare names resolve against the current repeat item first, then fall
+  // back to the state model at "/<name>".
   if (isTemplateExpression(value)) {
     return value.$template.replace(
       /\$\{([^}]+)\}/g,
       (_match, rawPath: string) => {
-        let path = rawPath;
-        if (!path.startsWith("/")) {
-          if (!warnedTemplatePaths.has(path)) {
-            if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
-              warnedTemplatePaths.add(path);
-            }
-            console.warn(
-              `$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
-            );
-          }
-          path = "/" + path;
+        if (rawPath.startsWith("/")) {
+          const resolved = getByPath(ctx.stateModel, rawPath);
+          return resolved != null ? String(resolved) : "";
+        }
+        // Bare name: try repeat item first
+        if (ctx.repeatItem !== undefined) {
+          const fromItem = getByPath(ctx.repeatItem, rawPath);
+          if (fromItem != null) return String(fromItem);
         }
-        const resolved = getByPath(ctx.stateModel, path);
+        // Fall back to state model
+        const resolved = getByPath(ctx.stateModel, "/" + rawPath);
         return resolved != null ? String(resolved) : "";
       },
     );

+ 2 - 2
packages/core/src/schema.ts

@@ -943,10 +943,10 @@ Note: state patches appear right after the elements that use them, so the UI fil
   );
   lines.push("");
   lines.push(
-    '4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
+    '4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates references in the string. Absolute paths like `${/path}` resolve against the state model. Bare names like `${field}` resolve against the current repeat item first, then fall back to the state model at `/<field>`.',
   );
   lines.push(
-    '   Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
+    '   Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00. Inside a repeat, `{ "$template": "${name} - ${email}" }` reads name and email from each item.',
   );
   lines.push("");